diff --git a/backend/tests/api/test_assistant_dataset_review_extended.py b/backend/tests/api/test_assistant_dataset_review_extended.py new file mode 100644 index 00000000..63f8075a --- /dev/null +++ b/backend/tests/api/test_assistant_dataset_review_extended.py @@ -0,0 +1,260 @@ +# #region Test.Assistant.DatasetReviewExtended [C:2] [TYPE Module] [SEMANTICS test,assistant,dataset,review,edge] +# @BRIEF Edge-case coverage for _dataset_review.py and _dataset_review_dispatch.py uncovered lines. +# @RELATION BINDS_TO -> [AssistantDatasetReview] +# @RELATION BINDS_TO -> [AssistantDatasetReviewDispatch] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from datetime import UTC, datetime +from unittest.mock import MagicMock, AsyncMock, patch +import pytest +from fastapi import HTTPException + + +class TestMatchDatasetReviewFieldExtended: + """_match_dataset_review_field — field_name and verbose_name matching paths.""" + + def test_match_by_field_name(self): + from src.api.routes.assistant._dataset_review import _match_dataset_review_field + + ctx = {"semantic_fields": [{"field_id": "fld-1", "field_name": "region", "verbose_name": "Region"}]} + result = _match_dataset_review_field(ctx, "update the region field please") + assert result is not None + assert result["field_name"] == "region" + + def test_match_by_verbose_name(self): + from src.api.routes.assistant._dataset_review import _match_dataset_review_field + + ctx = {"semantic_fields": [{"field_id": "fld-1", "field_name": "rgn", "verbose_name": "Region Name"}]} + result = _match_dataset_review_field(ctx, "change Region Name") + assert result is not None + assert result["verbose_name"] == "Region Name" + + def test_match_by_field_id_lowercase_in_message(self): + """Match field when field_id appears in message text.""" + from src.api.routes.assistant._dataset_review import _match_dataset_review_field + + ctx = {"semantic_fields": [{"field_id": "FLD-001", "field_name": "sales", "verbose_name": "Sales"}]} + # field_id "fld-001" does not appear in message — should try field_name/verbose + result = _match_dataset_review_field(ctx, "about sales data") + assert result is not None + assert result["field_name"] == "sales" + + +class TestPlanDatasetReviewIntentExtended: + """_plan_dataset_review_intent — uncovered branches.""" + + @pytest.fixture + def context(self): + return { + "session_id": "sess-1", + "version": 5, + "readiness_state": "REVIEW_READY", + "recommended_action": "REVIEW_DOCUMENTATION", + "mappings": [], + "findings": [{"code": "TEST"}], + "imported_filters": [{"filter_name": "region"}], + "preview": {"preview_status": "missing"}, + "semantic_fields": [ + { + "field_id": "fld-1", + "field_name": "region", + "verbose_name": "Region", + "candidates": [{"candidate_id": "cand-1"}], + } + ], + } + + def test_set_field_semantics_accept_candidate(self, context): + """Accept first candidate via message keywords.""" + from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent + result = _plan_dataset_review_intent( + 'apply field semantics target:field=fld-1 accept candidate', + context, + ) + assert result is not None + assert result["operation"] == "dataset_review_set_field_semantics" + assert result["entities"]["candidate_id"] == "cand-1" + + def test_set_field_semantics_with_label(self, context): + """Set verbose_name via 'label' keyword (matches regex alternation).""" + from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent + result = _plan_dataset_review_intent( + 'set field semantics target:field=fld-1 label="Country"', + context, + ) + assert result is not None + assert result["entities"]["verbose_name"] == "Country" + + def test_set_field_semantics_with_description(self, context): + """Set description via 'desc' keyword.""" + from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent + result = _plan_dataset_review_intent( + 'set field semantics target:field=fld-1 desc="The country field"', + context, + ) + assert result is not None + assert result["entities"]["description"] == "The country field" + + def test_set_field_semantics_no_attributes_returns_none(self, context): + """No candidate, verbose_name, description, or display_format returns None.""" + from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent + result = _plan_dataset_review_intent( + 'set field semantics target:field=fld-1', + context, + ) + assert result is None + + def test_set_field_semantics_with_lock(self, context): + """Lock field when message contains lock keywords.""" + from src.api.routes.assistant._dataset_review import _plan_dataset_review_intent + result = _plan_dataset_review_intent( + 'update semantic field target:field=fld-1 lock it desc="Locked"', + context, + ) + assert result is not None + assert result["entities"]["lock_field"] is True + + +class TestDispatchDatasetReviewIntentExtended: + """_dispatch_dataset_review_intent — uncovered branches.""" + + @pytest.fixture + def user(self): + from src.schemas.auth import User + return User(id="user-1", username="test", email="test@x.com", is_active=True, + auth_source="internal", created_at=datetime.now(UTC), + last_login=datetime.now(UTC), roles=[]) + + @pytest.fixture + def config_manager(self): + cm = MagicMock() + cm.get_environments = MagicMock(return_value=[]) + return cm + + def _make_session_mock(self, **overrides): + from src.models.dataset_review import ReadinessState, RecommendedAction, SessionStatus, SessionPhase + s = MagicMock() + s.session_id = overrides.get("session_id", "sess-1") + s.version = overrides.get("version", 5) + s.user_id = overrides.get("user_id", "user-1") + s.readiness_state = overrides.get("readiness_state", + MagicMock(value=ReadinessState.MAPPING_REVIEW_NEEDED.value)) + s.recommended_action = overrides.get("recommended_action", + MagicMock(value=RecommendedAction.REVIEW_DOCUMENTATION.value)) + s.execution_mappings = overrides.get("execution_mappings", []) + s.semantic_fields = overrides.get("semantic_fields", []) + s.last_activity_at = None + return s + + def _make_mapping_mock(self, **overrides): + from src.models.dataset_review import ApprovalState + m = MagicMock() + m.mapping_id = overrides.get("mapping_id", "m-1") + m.approval_state = overrides.get("approval_state", ApprovalState.PENDING) + m.requires_explicit_approval = overrides.get("requires_explicit_approval", True) + return m + + def _make_field_mock(self, **overrides): + f = MagicMock() + f.field_id = overrides.get("field_id", "fld-1") + f.field_name = overrides.get("field_name", "region") + return f + + def test_approve_mappings_updates_readiness_state(self, user, config_manager): + """When MAPPING_REVIEW_NEEDED, recommended_action changes to GENERATE_SQL_PREVIEW.""" + from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent + from src.models.dataset_review import ApprovalState + + mapping = self._make_mapping_mock(mapping_id="m-1", approval_state=ApprovalState.PENDING, + requires_explicit_approval=True) + session = self._make_session_mock(execution_mappings=[mapping], user_id="user-1") + + intent = { + "operation": "dataset_review_approve_mappings", + "entities": { + "dataset_review_session_id": "sess-1", + "session_version": 5, + "mapping_ids": ["m-1"], + }, + } + + db = MagicMock() + with patch("src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository") as mock_repo_cls: + mock_repo = MagicMock() + mock_repo_cls.return_value = mock_repo + mock_repo.load_session_detail.return_value = session + mock_repo.require_session_version.return_value = None + mock_repo.bump_session_version.return_value = None + mock_repo.db = MagicMock() + mock_repo.event_logger = MagicMock() + + text, task_id, actions = ( + __import__("asyncio").run(_dispatch_dataset_review_intent(intent, user, config_manager, db)) + ) + assert "Approved" in text + + def test_set_field_semantics_value_error(self, user, config_manager): + """ValueError from _update_semantic_field_state becomes 400.""" + from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent + + field = self._make_field_mock() + session = self._make_session_mock(semantic_fields=[field], user_id="user-1") + + intent = { + "operation": "dataset_review_set_field_semantics", + "entities": { + "dataset_review_session_id": "sess-1", + "session_version": 5, + "field_id": "fld-1", + "lock_field": True, + }, + } + + db = MagicMock() + with patch("src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository") as mock_repo_cls: + mock_repo = MagicMock() + mock_repo_cls.return_value = mock_repo + mock_repo.load_session_detail.return_value = session + mock_repo.require_session_version.return_value = None + + with patch("src.api.routes.assistant._dataset_review_dispatch._update_semantic_field_state", + side_effect=ValueError("Invalid field value")): + with pytest.raises(HTTPException) as exc: + __import__("asyncio").run( + _dispatch_dataset_review_intent(intent, user, config_manager, db) + ) + assert exc.value.status_code == 400 + assert "Invalid" in str(exc.value.detail) + + def test_set_field_semantics_field_not_found(self, user, config_manager): + """Field not found in session raises 404.""" + from src.api.routes.assistant._dataset_review_dispatch import _dispatch_dataset_review_intent + + session = self._make_session_mock(semantic_fields=[], user_id="user-1") + + intent = { + "operation": "dataset_review_set_field_semantics", + "entities": { + "dataset_review_session_id": "sess-1", + "session_version": 5, + "field_id": "nonexistent", + }, + } + + db = MagicMock() + with patch("src.api.routes.assistant._dataset_review_dispatch.DatasetReviewSessionRepository") as mock_repo_cls: + mock_repo = MagicMock() + mock_repo_cls.return_value = mock_repo + mock_repo.load_session_detail.return_value = session + mock_repo.require_session_version.return_value = None + + with pytest.raises(HTTPException) as exc: + __import__("asyncio").run( + _dispatch_dataset_review_intent(intent, user, config_manager, db) + ) + assert exc.value.status_code == 404 +# #endregion Test.Assistant.DatasetReviewExtended diff --git a/backend/tests/api/test_assistant_edge_cases.py b/backend/tests/api/test_assistant_edge_cases.py new file mode 100644 index 00000000..46603a17 --- /dev/null +++ b/backend/tests/api/test_assistant_edge_cases.py @@ -0,0 +1,967 @@ +# #region Test.Assistant.EdgeCases [C:2] [TYPE Module] [SEMANTICS test,assistant,routes,dispatch,resolvers,tools,edge] +# @BRIEF Edge-case coverage for _routes.py, _dispatch.py, _resolvers.py, and tool files. +# Covers confirmation/cancel routes, dry-run summary, resolver fallbacks, +# and tool handler error paths. +# @RELATION BINDS_TO -> [AssistantRoutes] +# @RELATION BINDS_TO -> [AssistantDispatch] +# @RELATION BINDS_TO -> [AssistantResolvers] +# @RELATION BINDS_TO -> [AssistantToolBackup] +# @RELATION BINDS_TO -> [AssistantToolMigration] +# @RELATION BINDS_TO -> [AssistantToolSearchDashboards] +# @RELATION BINDS_TO -> [AssistantToolTaskStatus] +# @RELATION BINDS_TO -> [AssistantToolRegistry] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock +import pytest +from fastapi import HTTPException + + +# ── Shared helpers ───────────────────────────────────────────────── + + +def _make_env(id_: str, name_: str, stage="DEV", is_production=False, is_default=False): + env = MagicMock() + env.id = id_ + env.name = name_ + env.stage = stage + env.is_production = is_production + env.is_default = is_default + return env + + +# ── _routes.py: confirm_operation and cancel_operation ──────────── + + +class TestAssistantRoutesConfirmation: + """confirm_operation and cancel_operation endpoints.""" + + @pytest.fixture + def current_user(self): + from src.schemas.auth import User + return User(id="user-1", username="test", email="test@x.com", is_active=True, + auth_source="internal", created_at=datetime.now(UTC), + last_login=datetime.now(UTC), roles=[]) + + # ── confirm_operation ── + + @pytest.mark.asyncio + async def test_confirm_found_in_memory(self, current_user): + """Confirm from in-memory CONFIRMATIONS store.""" + from src.api.routes.assistant._routes import confirm_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord + + CONFIRMATIONS.clear() + cid = "cf-test-1" + CONFIRMATIONS[cid] = ConfirmationRecord( + id=cid, + user_id="user-1", + conversation_id="conv-1", + intent={"operation": "show_capabilities", "entities": {}}, + dispatch={"intent": {}}, + expires_at=datetime.now(UTC) + timedelta(minutes=5), + created_at=datetime.now(UTC), + ) + + with patch("src.api.routes.assistant._routes.dispatch", + new=AsyncMock(return_value=("done", "task-1", []))) as mock_dispatch, \ + patch("src.api.routes.assistant._routes._append_history"), \ + patch("src.api.routes.assistant._routes._persist_message"), \ + patch("src.api.routes.assistant._routes._audit"), \ + patch("src.api.routes.assistant._routes._persist_audit"), \ + patch("src.api.routes.assistant._routes._update_confirmation_state"): + + resp = await confirm_operation( + confirmation_id=cid, + current_user=current_user, + task_manager=MagicMock(), + config_manager=MagicMock(), + db=MagicMock(), + ) + assert resp.state in ("success", "started") + mock_dispatch.assert_awaited_once() + + @pytest.mark.asyncio + async def test_confirm_not_found_404(self, current_user): + """Missing confirmation_id returns 404.""" + from src.api.routes.assistant._routes import confirm_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS + + CONFIRMATIONS.clear() + + with patch("src.api.routes.assistant._routes._load_confirmation_from_db", return_value=None): + with pytest.raises(HTTPException) as exc: + await confirm_operation( + confirmation_id="nonexistent", + current_user=current_user, + task_manager=MagicMock(), + config_manager=MagicMock(), + db=MagicMock(), + ) + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_confirm_wrong_user_403(self, current_user): + """Confirmation belonging to another user returns 403.""" + from src.api.routes.assistant._routes import confirm_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord + + CONFIRMATIONS.clear() + cid = "cf-wrong-user" + CONFIRMATIONS[cid] = ConfirmationRecord( + id=cid, user_id="user-2", conversation_id="conv-1", + intent={}, dispatch={}, + expires_at=datetime.now(UTC) + timedelta(minutes=5), + created_at=datetime.now(UTC), + ) + + with pytest.raises(HTTPException) as exc: + await confirm_operation( + confirmation_id=cid, current_user=current_user, + task_manager=MagicMock(), config_manager=MagicMock(), db=MagicMock(), + ) + assert exc.value.status_code == 403 + + @pytest.mark.asyncio + async def test_confirm_already_consumed_400(self, current_user): + """Already consumed/expired confirmation returns 400.""" + from src.api.routes.assistant._routes import confirm_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord + + CONFIRMATIONS.clear() + cid = "cf-consumed" + CONFIRMATIONS[cid] = ConfirmationRecord( + id=cid, user_id="user-1", conversation_id="conv-1", + intent={}, dispatch={}, state="consumed", + expires_at=datetime.now(UTC) + timedelta(minutes=5), + created_at=datetime.now(UTC), + ) + + with pytest.raises(HTTPException) as exc: + await confirm_operation( + confirmation_id=cid, current_user=current_user, + task_manager=MagicMock(), config_manager=MagicMock(), db=MagicMock(), + ) + assert exc.value.status_code == 400 + assert "already" in str(exc.value.detail) + + @pytest.mark.asyncio + async def test_confirm_expired_400(self, current_user): + """Expired confirmation returns 400.""" + from src.api.routes.assistant._routes import confirm_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord + + CONFIRMATIONS.clear() + cid = "cf-expired" + CONFIRMATIONS[cid] = ConfirmationRecord( + id=cid, user_id="user-1", conversation_id="conv-1", + intent={}, dispatch={}, state="pending", + expires_at=datetime.now(UTC) - timedelta(minutes=5), + created_at=datetime.now(UTC) - timedelta(minutes=10), + ) + + with patch("src.api.routes.assistant._routes._update_confirmation_state"): + with pytest.raises(HTTPException) as exc: + await confirm_operation( + confirmation_id=cid, current_user=current_user, + task_manager=MagicMock(), config_manager=MagicMock(), db=MagicMock(), + ) + assert exc.value.status_code == 400 + assert "expired" in str(exc.value.detail) + + @pytest.mark.asyncio + async def test_confirm_loaded_from_db(self, current_user): + """Confirmation loaded from DB when not in memory.""" + from src.api.routes.assistant._routes import confirm_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord + + CONFIRMATIONS.clear() + cid = "cf-db-loaded" + + db_record = ConfirmationRecord( + id=cid, user_id="user-1", conversation_id="conv-1", + intent={"operation": "show_capabilities", "entities": {}}, dispatch={}, + expires_at=datetime.now(UTC) + timedelta(minutes=5), + created_at=datetime.now(UTC), + ) + + with patch("src.api.routes.assistant._routes._load_confirmation_from_db", return_value=db_record), \ + patch("src.api.routes.assistant._routes.dispatch", + new=AsyncMock(return_value=("done", None, []))), \ + patch("src.api.routes.assistant._routes._append_history"), \ + patch("src.api.routes.assistant._routes._persist_message"), \ + patch("src.api.routes.assistant._routes._audit"), \ + patch("src.api.routes.assistant._routes._persist_audit"), \ + patch("src.api.routes.assistant._routes._update_confirmation_state"): + + resp = await confirm_operation( + confirmation_id=cid, current_user=current_user, + task_manager=MagicMock(), config_manager=MagicMock(), db=MagicMock(), + ) + assert resp.state in ("success", "started") + + # ── cancel_operation ── + + @pytest.mark.asyncio + async def test_cancel_success(self, current_user): + """Cancel pending confirmation.""" + from src.api.routes.assistant._routes import cancel_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord + + CONFIRMATIONS.clear() + cid = "cf-cancel-1" + CONFIRMATIONS[cid] = ConfirmationRecord( + id=cid, user_id="user-1", conversation_id="conv-1", + intent={"operation": "deploy_dashboard"}, dispatch={}, + expires_at=datetime.now(UTC) + timedelta(minutes=5), + created_at=datetime.now(UTC), + ) + + with patch("src.api.routes.assistant._routes._update_confirmation_state"), \ + patch("src.api.routes.assistant._routes._append_history"), \ + patch("src.api.routes.assistant._routes._persist_message"), \ + patch("src.api.routes.assistant._routes._audit"), \ + patch("src.api.routes.assistant._routes._persist_audit"): + + resp = await cancel_operation( + confirmation_id=cid, current_user=current_user, db=MagicMock(), + ) + assert resp.state == "success" + assert "отменена" in resp.text + + @pytest.mark.asyncio + async def test_cancel_not_found_404(self, current_user): + """Cancel nonexistent confirmation.""" + from src.api.routes.assistant._routes import cancel_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS + + CONFIRMATIONS.clear() + + with patch("src.api.routes.assistant._routes._load_confirmation_from_db", return_value=None): + with pytest.raises(HTTPException) as exc: + await cancel_operation( + confirmation_id="nonexistent", current_user=current_user, db=MagicMock(), + ) + assert exc.value.status_code == 404 + + @pytest.mark.asyncio + async def test_cancel_wrong_user_403(self, current_user): + """Cancel belonging to another user.""" + from src.api.routes.assistant._routes import cancel_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord + + CONFIRMATIONS.clear() + cid = "cf-cancel-wrong" + CONFIRMATIONS[cid] = ConfirmationRecord( + id=cid, user_id="user-2", conversation_id="conv-1", + intent={}, dispatch={}, + expires_at=datetime.now(UTC) + timedelta(minutes=5), + created_at=datetime.now(UTC), + ) + + with pytest.raises(HTTPException) as exc: + await cancel_operation( + confirmation_id=cid, current_user=current_user, db=MagicMock(), + ) + assert exc.value.status_code == 403 + + @pytest.mark.asyncio + async def test_cancel_already_consumed_400(self, current_user): + """Cancel already consumed confirmation.""" + from src.api.routes.assistant._routes import cancel_operation + from src.api.routes.assistant._schemas import CONFIRMATIONS, ConfirmationRecord + + CONFIRMATIONS.clear() + cid = "cf-cancel-consumed" + CONFIRMATIONS[cid] = ConfirmationRecord( + id=cid, user_id="user-1", conversation_id="conv-1", + intent={}, dispatch={}, state="consumed", + expires_at=datetime.now(UTC) + timedelta(minutes=5), + created_at=datetime.now(UTC), + ) + + with pytest.raises(HTTPException) as exc: + await cancel_operation( + confirmation_id=cid, current_user=current_user, db=MagicMock(), + ) + assert exc.value.status_code == 400 + + # ── _routes.py line 150: failed state (non-clarification HTTPException) ── + + @pytest.mark.asyncio + async def test_send_message_failed_state(self): + """Non-403, non-clarification HTTPException results in 'failed' state.""" + from src.api.routes.assistant._routes import send_message + from src.api.routes.assistant._schemas import AssistantMessageRequest, CONFIRMATIONS + from src.api.routes.assistant._tool_registry import _tools + + CONFIRMATIONS.clear() + _tools.clear() + + req = AssistantMessageRequest(message="do something") + + with patch("src.api.routes.assistant._routes._resolve_or_create_conversation", return_value="conv-1"), \ + patch("src.api.routes.assistant._routes._append_history"), \ + patch("src.api.routes.assistant._routes._persist_message"), \ + patch("src.api.routes.assistant._routes._build_tool_catalog", return_value=[]), \ + patch("src.api.routes.assistant._routes._plan_intent_with_llm", + return_value={"domain": "d", "operation": "op", "entities": {}, "confidence": 0.9, "risk_level": "guarded"}), \ + patch("src.api.routes.assistant._routes._authorize_intent"), \ + patch("src.api.routes.assistant._routes.get_safe_ops", return_value={"op"}), \ + patch("src.api.routes.assistant._routes.dispatch", + side_effect=HTTPException(status_code=500, detail="Internal error")), \ + patch("src.api.routes.assistant._routes._audit"), \ + patch("src.api.routes.assistant._routes._persist_audit"), \ + patch("src.api.routes.assistant._routes._load_dataset_review_context", return_value=None): + + current_user = MagicMock(id="user-1") + resp = await send_message( + req, current_user, MagicMock(), MagicMock(), MagicMock() + ) + assert resp.state == "failed" + + @pytest.mark.asyncio + async def test_send_message_dataset_review_overrides_intent(self): + """dataset_review_intent overrides intent when context is active.""" + from src.api.routes.assistant._routes import send_message + from src.api.routes.assistant._schemas import AssistantMessageRequest, CONFIRMATIONS + from src.api.routes.assistant._tool_registry import _tools + + CONFIRMATIONS.clear() + _tools.clear() + + req = AssistantMessageRequest(message="show filters", dataset_review_session_id="sess-1") + + with patch("src.api.routes.assistant._routes._resolve_or_create_conversation", return_value="conv-1"), \ + patch("src.api.routes.assistant._routes._append_history"), \ + patch("src.api.routes.assistant._routes._persist_message"), \ + patch("src.api.routes.assistant._routes._build_tool_catalog", return_value=[]), \ + patch("src.api.routes.assistant._routes._plan_intent_with_llm", + return_value={"domain": "original", "operation": "orig_op", "entities": {}, "confidence": 0.9}), \ + patch("src.api.routes.assistant._routes._parse_command", + return_value={"domain": "unknown", "operation": "clarify", "entities": {}, "confidence": 0.3}), \ + patch("src.api.routes.assistant._routes._plan_dataset_review_intent", + return_value={"domain": "dataset_review", "operation": "dataset_review_answer_context", + "entities": {"summary": "test"}, "confidence": 0.8, "risk_level": "safe", + "requires_confirmation": False}), \ + patch("src.api.routes.assistant._routes._authorize_intent"), \ + patch("src.api.routes.assistant._routes.get_safe_ops", return_value={"dataset_review_answer_context"}), \ + patch("src.api.routes.assistant._routes.dispatch", + new=AsyncMock(return_value=("context answer", None, []))), \ + patch("src.api.routes.assistant._routes._audit"), \ + patch("src.api.routes.assistant._routes._persist_audit"), \ + patch("src.api.routes.assistant._routes._load_dataset_review_context", + return_value={"session_id": "sess-1"}): + + current_user = MagicMock(id="user-1") + resp = await send_message( + req, current_user, MagicMock(), MagicMock(), MagicMock() + ) + assert resp.state in ("success", "started") + + +# ── _dispatch.py: dry-run code path ────────────────────────────── + + +class TestDispatchDryRunSummary: + """_async_confirmation_summary with dry_run enabled.""" + + @pytest.fixture + def config_manager(self): + cm = MagicMock() + env = _make_env("env-dev", "Development") + cm.get_environments = MagicMock(return_value=[env]) + return cm + + @pytest.mark.asyncio + async def test_migration_with_dry_run_success(self, config_manager): + """Execute migration with dry_run=True triggers dry-run report.""" + from src.api.routes.assistant._dispatch import _async_confirmation_summary + + db = MagicMock() + intent = { + "operation": "execute_migration", + "entities": { + "dashboard_id": "42", + "source_env": "dev", + "target_env": "staging", + "replace_db_config": True, + "fix_cross_filters": True, + "dry_run": True, + }, + } + + with patch("src.api.routes.assistant._dispatch._resolve_dashboard_id_entity", + new=AsyncMock(return_value=42)), \ + patch("src.api.routes.assistant._dispatch._resolve_env_id") as mock_resolve_env, \ + patch("src.core.migration.dry_run_orchestrator.MigrationDryRunService") as mock_dry_cls, \ + patch("src.core.async_superset_client.AsyncSupersetClient"): + + mock_resolve_env.side_effect = lambda t, cm: {"dev": "env-dev", "staging": "env-staging", "prod": "env-prod"}.get(t) + + mock_dry = MagicMock() + mock_dry_cls.return_value = mock_dry + mock_dry.run.return_value = { + "summary": { + "dashboards": {"create": 1, "update": 2, "delete": 0}, + "charts": {"create": 3, "update": 4, "delete": 1}, + "datasets": {"create": 0, "update": 0, "delete": 0}, + } + } + + text = await _async_confirmation_summary(intent, config_manager, db) + assert "dry-run" in text.lower() or "dry" in text.lower() + assert "создано" in text + + @pytest.mark.asyncio + async def test_migration_dry_run_missing_env_mismatch(self, config_manager): + """Dry-run report gracefully handles environment mismatch.""" + from src.api.routes.assistant._dispatch import _async_confirmation_summary + + db = MagicMock() + intent = { + "operation": "execute_migration", + "entities": { + "dashboard_id": "42", + "source_env": "prod", + "target_env": "prod", + "dry_run": True, + }, + } + + with patch("src.api.routes.assistant._dispatch._resolve_dashboard_id_entity", + new=AsyncMock(return_value=None)): + text = await _async_confirmation_summary(intent, config_manager, db) + assert "Подтвердите" in text or "Выполнить" in text + + @pytest.mark.asyncio + async def test_migration_dry_run_exception_handling(self, config_manager): + """Dry-run exception is caught gracefully.""" + from src.api.routes.assistant._dispatch import _async_confirmation_summary + + db = MagicMock() + intent = { + "operation": "execute_migration", + "entities": { + "dashboard_id": "42", + "source_env": "dev", + "target_env": "staging", + "dry_run": True, + }, + } + + with patch("src.api.routes.assistant._dispatch._resolve_dashboard_id_entity", + new=AsyncMock(side_effect=Exception("Unexpected error"))): + text = await _async_confirmation_summary(intent, config_manager, db) + assert "Подтвердите" in text or "Выполнить" in text + + +# ── _resolvers.py: edge cases ──────────────────────────────────── + + +class TestAssistantResolversExtended: + """Edge cases for resolver functions.""" + + def test_is_production_env_no_env_found(self): + """_is_production_env returns False when token doesn't match any env.""" + from src.api.routes.assistant._resolvers import _is_production_env + + config_manager = MagicMock() + env1 = _make_env("env-dev", "Development") + config_manager.get_environments = MagicMock(return_value=[env1]) + + # Token matches env id but env name doesn't contain "prod" + assert _is_production_env("env-dev", config_manager) is False + + def test_resolve_provider_id_with_bound_provider(self): + """_resolve_provider_id uses resolve_bound_provider_id when config_manager and task_key given.""" + from src.api.routes.assistant._resolvers import _resolve_provider_id + + db = MagicMock() + config_manager = MagicMock() + p1 = MagicMock(id="p1", name="OpenAI", is_active=True) + + with patch("src.api.routes.assistant._resolvers.LLMProviderService") as mock_svc_cls: + svc = MagicMock() + mock_svc_cls.return_value = svc + svc.get_all_providers.return_value = [p1] + + with patch("src.api.routes.assistant._resolvers.resolve_bound_provider_id", + return_value="p1"): + result = _resolve_provider_id(None, db, config_manager, "validation") + assert result == "p1" + + def test_resolve_provider_id_bound_provider_not_found(self): + """_resolve_provider_id falls back to active when bound provider not found.""" + from src.api.routes.assistant._resolvers import _resolve_provider_id + + db = MagicMock() + config_manager = MagicMock() + p1 = MagicMock(id="p1", name="OpenAI", is_active=True) + + with patch("src.api.routes.assistant._resolvers.LLMProviderService") as mock_svc_cls: + svc = MagicMock() + mock_svc_cls.return_value = svc + svc.get_all_providers.return_value = [p1] + + with patch("src.api.routes.assistant._resolvers.resolve_bound_provider_id", + return_value="nonexistent"): + result = _resolve_provider_id(None, db, config_manager, "validation") + assert result == "p1" + + def test_resolve_provider_id_bound_provider_exception(self): + """_resolve_provider_id handles exception from resolve_bound_provider_id.""" + from src.api.routes.assistant._resolvers import _resolve_provider_id + + db = MagicMock() + config_manager = MagicMock() + p1 = MagicMock(id="p1", name="OpenAI", is_active=True) + + with patch("src.api.routes.assistant._resolvers.LLMProviderService") as mock_svc_cls: + svc = MagicMock() + mock_svc_cls.return_value = svc + svc.get_all_providers.return_value = [p1] + + with patch("src.api.routes.assistant._resolvers.resolve_bound_provider_id", + side_effect=Exception("Config error")): + result = _resolve_provider_id(None, db, config_manager, "validation") + assert result == "p1" + + def test_get_default_environment_id_error_in_get_config(self): + """_get_default_environment_id handles get_config exception.""" + from src.api.routes.assistant._resolvers import _get_default_environment_id + + config_manager = MagicMock() + env1 = _make_env("env-dev", "Dev", is_default=True) + config_manager.get_environments = MagicMock(return_value=[env1]) + config_manager.get_config.side_effect = Exception("Config error") + + result = _get_default_environment_id(config_manager) + assert result == "env-dev" # falls back to explicit default + + def test_get_default_environment_id_no_explicit_default(self): + """_get_default_environment_id falls back to first env.""" + from src.api.routes.assistant._resolvers import _get_default_environment_id + + config_manager = MagicMock() + env1 = _make_env("env-dev", "Dev", is_default=False) + env2 = _make_env("env-prod", "Prod", is_default=False) + config_manager.get_environments = MagicMock(return_value=[env1, env2]) + + result = _get_default_environment_id(config_manager) + assert result == "env-dev" # first in list + + @pytest.mark.asyncio + async def test_resolve_dashboard_id_by_ref_with_exception(self): + """_resolve_dashboard_id_by_ref handles SupersetClient exception.""" + from src.api.routes.assistant._resolvers import _resolve_dashboard_id_by_ref + + config_manager = MagicMock() + env = _make_env("env-dev", "Development") + config_manager.get_environments = MagicMock(return_value=[env]) + + with patch("src.api.routes.assistant._resolvers.SupersetClient") as mock_client_cls: + client = MagicMock() + mock_client_cls.return_value = client + client.get_dashboards = AsyncMock(side_effect=Exception("API error")) + + result = await _resolve_dashboard_id_by_ref("my-dash", "env-dev", config_manager) + assert result is None + + @pytest.mark.asyncio + async def test_resolve_dashboard_id_by_ref_partial_match(self): + """_resolve_dashboard_id_by_ref returns single partial match.""" + from src.api.routes.assistant._resolvers import _resolve_dashboard_id_by_ref + + config_manager = MagicMock() + env = _make_env("env-dev", "Development") + config_manager.get_environments = MagicMock(return_value=[env]) + + with patch("src.api.routes.assistant._resolvers.SupersetClient") as mock_client_cls: + client = MagicMock() + mock_client_cls.return_value = client + client.get_dashboards = AsyncMock(return_value=(None, [ + {"id": 10, "dashboard_title": "COVID Dashboard", "slug": "covid"}, + {"id": 20, "dashboard_title": "Sales Dashboard", "slug": "sales"}, + ])) + + result = await _resolve_dashboard_id_by_ref("covid", "env-dev", config_manager) + assert result == 10 + + @pytest.mark.asyncio + async def test_resolve_dashboard_id_by_ref_partial_match_by_title(self): + """_resolve_dashboard_id_by_ref matches by title substring.""" + from src.api.routes.assistant._resolvers import _resolve_dashboard_id_by_ref + + config_manager = MagicMock() + env = _make_env("env-dev", "Development") + config_manager.get_environments = MagicMock(return_value=[env]) + + with patch("src.api.routes.assistant._resolvers.SupersetClient") as mock_client_cls: + client = MagicMock() + mock_client_cls.return_value = client + client.get_dashboards = AsyncMock(return_value=(None, [ + {"id": 1, "dashboard_title": "COVID Dashboard", "slug": "covid"}, + {"id": 2, "dashboard_title": "COVID Overview", "slug": "covid-overview"}, + ])) + + # Partial match with multiple results -> None + result = await _resolve_dashboard_id_by_ref("covid", "env-dev", config_manager) + assert result == 1 # exact slug match "covid" + + def test_extract_result_deep_links_migration_params_fallback(self): + """Deep links fallback to params selected_ids when migrated list is empty.""" + from src.api.routes.assistant._resolvers import _extract_result_deep_links + + config_manager = MagicMock() + env = _make_env("env-prod", "Production") + config_manager.get_environments = MagicMock(return_value=[env]) + + task = MagicMock( + plugin_id="superset-migration", + params={"selected_ids": [123], "target_env_id": "env-prod"}, + result={"migrated_dashboards": None}, + ) + actions = _extract_result_deep_links(task, config_manager) + assert len(actions) >= 1 + + def test_extract_result_deep_links_backup_params_fallback(self): + """Deep links fallback to params dashboard_ids when result list is empty.""" + from src.api.routes.assistant._resolvers import _extract_result_deep_links + + config_manager = MagicMock() + env = _make_env("env-dev", "Dev") + config_manager.get_environments = MagicMock(return_value=[env]) + + task = MagicMock( + plugin_id="superset-backup", + params={"dashboard_ids": [456], "environment_id": "env-dev"}, + result={"dashboards": None}, + ) + actions = _extract_result_deep_links(task, config_manager) + assert len(actions) >= 1 + + def test_extract_result_deep_links_backup_env_from_result(self): + """Backup deep links resolves environment from result when not in params.""" + from src.api.routes.assistant._resolvers import _extract_result_deep_links + + config_manager = MagicMock() + env = _make_env("env-dev", "Dev") + config_manager.get_environments = MagicMock(return_value=[env]) + + task = MagicMock( + plugin_id="superset-backup", + params={}, + result={"dashboards": [{"id": 456}], "environment": "dev"}, + ) + with patch("src.api.routes.assistant._resolvers._resolve_env_id", return_value="env-dev"): + actions = _extract_result_deep_links(task, config_manager) + assert len(actions) >= 1 + + +# ── _tool_backup.py: dashboard_id resolution failure ──────────── + + +class TestToolBackupEdge: + """Edge cases for backup tool.""" + + @pytest.mark.asyncio + async def test_run_backup_dashboard_resolution_failure(self): + """Backup with dashboard_ref that fails resolution raises 422.""" + from src.api.routes.assistant._tool_backup import handle_run_backup + from src.schemas.auth import User + + user = User(id="user-1", username="test", email="test@x.com", is_active=True, + auth_source="internal", created_at=datetime.now(UTC), + last_login=datetime.now(UTC), roles=[]) + task_manager = MagicMock() + config_manager = MagicMock() + db = MagicMock() + + intent = { + "entities": { + "environment": "dev", + "dashboard_ref": "nonexistent", + } + } + + with patch("src.api.routes.assistant._tool_backup._check_any_permission"), \ + patch("src.api.routes.assistant._tool_backup._resolve_env_id", return_value="env-dev"), \ + patch("src.api.routes.assistant._tool_backup._resolve_dashboard_id_entity", + new=AsyncMock(return_value=None)): + + with pytest.raises(HTTPException) as exc: + await handle_run_backup(intent, user, task_manager, config_manager, db) + assert exc.value.status_code == 422 + + +# ── _tool_migration.py: dashboard_ref path ─────────────────────── + + +class TestToolMigrationEdge: + """Edge cases for migration tool.""" + + @pytest.mark.asyncio + async def test_execute_migration_with_dashboard_ref(self): + """Migration with dashboard_ref uses regex path.""" + from src.api.routes.assistant._tool_migration import handle_execute_migration + from src.schemas.auth import User + + user = User(id="user-1", username="test", email="test@x.com", is_active=True, + auth_source="internal", created_at=datetime.now(UTC), + last_login=datetime.now(UTC), roles=[]) + task_manager = MagicMock() + task_manager.create_task = AsyncMock(return_value=MagicMock(id="task-55")) + config_manager = MagicMock() + db = MagicMock() + + intent = { + "entities": { + "source_env": "dev", + "target_env": "staging", + "dashboard_ref": "my-dashboard-slug", + } + } + + with patch("src.api.routes.assistant._tool_migration._check_any_permission"), \ + patch("src.api.routes.assistant._tool_migration._resolve_dashboard_id_entity", + new=AsyncMock(return_value=None)), \ + patch("src.api.routes.assistant._tool_migration._resolve_env_id", + side_effect=lambda t, cm: {"dev": "env-dev", "staging": "env-staging"}.get(t)): + + text, task_id, actions = await handle_execute_migration( + intent, user, task_manager, config_manager, db + ) + assert "Миграция запущена" in text + assert task_id == "task-55" + # Verify dashboard_regex was used + call_kwargs = task_manager.create_task.call_args[1] + assert "dashboard_regex" in call_kwargs["params"] + + @pytest.mark.asyncio + async def test_execute_migration_missing_both_dashboard_refs(self): + """Migration without dashboard_id AND dashboard_ref raises 422.""" + from src.api.routes.assistant._tool_migration import handle_execute_migration + from src.schemas.auth import User + + user = User(id="user-1", username="test", email="test@x.com", is_active=True, + auth_source="internal", created_at=datetime.now(UTC), + last_login=datetime.now(UTC), roles=[]) + task_manager = MagicMock() + config_manager = MagicMock() + db = MagicMock() + + intent = { + "entities": { + "source_env": "dev", + "target_env": "staging", + } + } + + with patch("src.api.routes.assistant._tool_migration._check_any_permission"), \ + patch("src.api.routes.assistant._tool_migration._resolve_env_id", + return_value="env-dev"): + + with pytest.raises(HTTPException) as exc: + await handle_execute_migration(intent, user, task_manager, config_manager, db) + assert exc.value.status_code == 422 + + +# ── _tool_search_dashboards.py: no results and pagination ──────── + + +class TestToolSearchDashboardsEdge: + """Edge cases for search dashboards tool.""" + + @pytest.mark.asyncio + async def test_search_with_no_results(self): + """Search with no matching dashboards returns 'not found' message.""" + from src.api.routes.assistant._tool_search_dashboards import handle_search_dashboards + + config_manager = MagicMock() + env = _make_env("env-dev", "Development") + config_manager.get_environments = MagicMock(return_value=[env]) + db = MagicMock() + + intent = {"entities": {"environment": "dev", "search": "nonexistent"}} + + with patch("src.api.routes.assistant._tool_search_dashboards._resolve_env_id", + return_value="env-dev"), \ + patch("src.api.routes.assistant._tool_search_dashboards.SupersetClient") as mock_cls: + + client = MagicMock() + mock_cls.return_value = client + client.get_dashboards_summary = AsyncMock(return_value=[ + {"id": 1, "title": "Dashboard One", "slug": "dash-one"}, + ]) + + text, task_id, actions = await handle_search_dashboards( + intent, MagicMock(id="user-1"), MagicMock(), config_manager, db + ) + assert "не найдены" in text + assert task_id is None + + @pytest.mark.asyncio + async def test_search_empty_env_no_dashboards(self): + """Environment with no dashboards at all.""" + from src.api.routes.assistant._tool_search_dashboards import handle_search_dashboards + + config_manager = MagicMock() + env = _make_env("env-dev", "Development") + config_manager.get_environments = MagicMock(return_value=[env]) + db = MagicMock() + + intent = {"entities": {"environment": "dev"}} + + with patch("src.api.routes.assistant._tool_search_dashboards._resolve_env_id", + return_value="env-dev"), \ + patch("src.api.routes.assistant._tool_search_dashboards.SupersetClient") as mock_cls: + + client = MagicMock() + mock_cls.return_value = client + client.get_dashboards_summary = AsyncMock(return_value=[]) + + text, task_id, actions = await handle_search_dashboards( + intent, MagicMock(id="user-1"), MagicMock(), config_manager, db + ) + assert "нет дашбордов" in text + + @pytest.mark.asyncio + async def test_search_with_pagination_tail(self): + """Pagination '... and more' indicator.""" + from src.api.routes.assistant._tool_search_dashboards import handle_search_dashboards + + config_manager = MagicMock() + env = _make_env("env-dev", "Development") + config_manager.get_environments = MagicMock(return_value=[env]) + db = MagicMock() + + dashboards = [{"id": i, "title": f"Dashboard {i}", "slug": f"dash-{i}"} for i in range(1, 25)] + + intent = {"entities": {"environment": "dev", "page": 1, "page_size": 20}} + + with patch("src.api.routes.assistant._tool_search_dashboards._resolve_env_id", + return_value="env-dev"), \ + patch("src.api.routes.assistant._tool_search_dashboards.SupersetClient") as mock_cls: + + client = MagicMock() + mock_cls.return_value = client + client.get_dashboards_summary = AsyncMock(return_value=dashboards) + + text, task_id, actions = await handle_search_dashboards( + intent, MagicMock(id="user-1"), MagicMock(), config_manager, db + ) + assert "и ещё" in text + + +# ── _tool_registry.py: get_catalog filtered and defaults ───────── + + +class TestToolRegistryEdge: + """Edge cases for tool registry.""" + + def _clear_registry(self): + import src.api.routes.assistant._tool_registry as reg + reg._tools.clear() + + def test_get_catalog_filters_by_permission(self): + """get_catalog skips tools user doesn't have permission for.""" + from src.api.routes.assistant._tool_registry import assistant_tool, get_catalog, _tools + from src.schemas.auth import User + + self._clear_registry() + + @assistant_tool(operation="admin_op", domain="admin", description="Admin only", + permission_checks=[("admin", "ACCESS")]) + async def admin_handler(i, u, t, c, d): return ("", None, []) + + @assistant_tool(operation="public_op", domain="public", description="Public", + risk_level="safe") + async def public_handler(i, u, t, c, d): return ("", None, []) + + user = User(id="u1", username="test", email="t@t.com", is_active=True, + auth_source="internal", created_at=datetime.now(UTC), + last_login=datetime.now(UTC), roles=[]) + + with patch("src.api.routes.assistant._tool_registry._has_any_permission", + return_value=False): + catalog = get_catalog(user, MagicMock(), MagicMock()) + ops = [e["operation"] for e in catalog] + assert "admin_op" not in ops + assert "public_op" in ops + + def test_get_catalog_with_defaults(self): + """get_catalog includes defaults when tool has them.""" + from src.api.routes.assistant._tool_registry import assistant_tool, get_catalog, _tools + + self._clear_registry() + + @assistant_tool(operation="with_defaults", domain="t", description="t", + defaults={"environment": "dev"}) + async def h(i, u, t, c, d): return ("", None, []) + + catalog = get_catalog(MagicMock(), MagicMock(), MagicMock()) + entry = next(e for e in catalog if e["operation"] == "with_defaults") + assert "defaults" in entry + assert entry["defaults"]["environment"] == "dev" + + @pytest.mark.asyncio + async def test_dispatch_dataset_review_ops(self): + """dispatch routes dataset_review ops through sub-dispatcher.""" + from src.api.routes.assistant._tool_registry import dispatch + + with patch( + "src.api.routes.assistant._dataset_review_dispatch._dispatch_dataset_review_intent", + new=AsyncMock(return_value=("review result", None, [])), + ) as mock_sub: + text, tid, acts = await dispatch( + "dataset_review_answer_context", {}, MagicMock(), MagicMock(), MagicMock(), MagicMock() + ) + assert text == "review result" + mock_sub.assert_awaited_once() + + +# ── _tool_task_status.py: completed task with deep links ───────── + + +class TestToolTaskStatusEdge: + """Edge cases for task status tool.""" + + @pytest.mark.asyncio + async def test_get_task_status_by_id_with_deep_links(self): + """Task status with SUCCESS status adds deep link actions.""" + from src.api.routes.assistant._tool_task_status import handle_get_task_status + from src.api.routes.assistant._schemas import AssistantAction + from src.schemas.auth import User + + user = User(id="user-1", username="test", email="test@x.com", is_active=True, + auth_source="internal", created_at=datetime.now(UTC), + last_login=datetime.now(UTC), roles=[]) + task_manager = MagicMock() + config_manager = MagicMock() + db = MagicMock() + + with patch("src.api.routes.assistant._tool_task_status._check_any_permission"), \ + patch("src.api.routes.assistant._tool_task_status._build_task_observability_summary", + return_value="Detailed summary"), \ + patch("src.api.routes.assistant._tool_task_status._extract_result_deep_links", + return_value=[ + AssistantAction(type="open_route", label="Open Dashboard", target="/dashboards/42") + ]): + + task = MagicMock(id="task-42", status="SUCCESS", user_id="user-1") + task_manager.get_task.return_value = task + + intent = {"entities": {"task_id": "task-42"}} + text, task_id, actions = await handle_get_task_status( + intent, user, task_manager, config_manager, db + ) + assert "task-42" in text + assert len(actions) > 1 # open_task + deep links + assert task_id == "task-42" +# #endregion Test.Assistant.EdgeCases diff --git a/backend/tests/api/test_assistant_tool_maintenance.py b/backend/tests/api/test_assistant_tool_maintenance.py index 96706230..bc98460a 100644 --- a/backend/tests/api/test_assistant_tool_maintenance.py +++ b/backend/tests/api/test_assistant_tool_maintenance.py @@ -108,12 +108,15 @@ class TestHandleListMaintenanceEvents: def query_side_effect(model): q = MagicMock() if model.__name__ == "MaintenanceEvent": - q.filter.return_value = MagicMock() - q.filter.return_value.filter.return_value.all.return_value = [] - q.filter.return_value.filter.return_value.order_by.return_value.all.return_value = [] - q.filter.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [] + # Expired: query().filter(a, b, c).all() + q.filter.return_value.all.return_value = [] + # Active: query().filter(...).order_by().all() + q.filter.return_value.order_by.return_value.all.return_value = [] + # Completed: query().filter(...).order_by().limit().all() + q.filter.return_value.order_by.return_value.limit.return_value.all.return_value = [] else: - q.filter.return_value.filter.return_value.count.return_value = 0 + # MaintenanceDashboardState: query().filter(...).count() — won't be reached + q.filter.return_value.count.return_value = 0 return q db.query.side_effect = query_side_effect @@ -369,7 +372,8 @@ class TestHandleEndMaintenance: _make_maintenance_event("evt-1", MaintenanceEventStatus.ACTIVE), _make_maintenance_event("evt-2", MaintenanceEventStatus.PARTIAL), ] - db.query.return_value.filter.return_value.filter.return_value.all.return_value = active_events + # Code: db.query(MaintenanceEvent).filter(...).all() + db.query.return_value.filter.return_value.all.return_value = active_events intent["entities"] = {} # No event_id -> end all @@ -387,7 +391,8 @@ class TestHandleEndMaintenance: config_manager = MagicMock() db = MagicMock() - db.query.return_value.filter.return_value.filter.return_value.all.return_value = [] + # Code: db.query(MaintenanceEvent).filter(...).all() + db.query.return_value.filter.return_value.all.return_value = [] intent["entities"] = {} # No event_id diff --git a/backend/tests/api/test_dashboard_detail_routes.py b/backend/tests/api/test_dashboard_detail_routes.py index 59c2ba2c..3137925f 100644 --- a/backend/tests/api/test_dashboard_detail_routes.py +++ b/backend/tests/api/test_dashboard_detail_routes.py @@ -172,6 +172,24 @@ class TestGetDashboardDetail: assert resp.status_code == 404 assert "Environment not found" in resp.text + def test_detail_service_error_503(self): + mock_config = MagicMock() + mock_config.get_environments.return_value = [_make_mock_env(id="env-1")] + + mock_client = AsyncMock() + mock_client.get_dashboard_detail.side_effect = Exception("Superset API error") + mock_client.get_dashboards_page.return_value = (1, [{"id": 42}]) + + from src.dependencies import get_config_manager + + with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \ + patch("src.api.routes.dashboards._detail_routes.get_superset_client") as mock_reg: + mock_reg.return_value = AsyncMock() + client = _make_client({get_config_manager: lambda: mock_config}) + resp = client.get("/api/dashboards/42?env_id=env-1") + assert resp.status_code == 503 + assert "Failed to fetch dashboard detail" in resp.text + # ── get_dashboard_tasks_history ── @@ -294,4 +312,110 @@ class TestGetDashboardThumbnail: resp = client.get("/api/dashboards/42/thumbnail?env_id=env-ghost") assert resp.status_code == 404 assert "Environment not found" in resp.text + + def test_thumbnail_202_accepted(self): + """Superset returns 202 when thumbnail is being generated.""" + mock_config = MagicMock() + mock_config.get_environments.return_value = [_make_mock_env(id="env-1")] + + mock_client = AsyncMock() + mock_client.get_dashboards_page.return_value = (1, [{"id": 42}]) + + mock_async_client = AsyncMock() + + async def _fake_request_202(method, endpoint, **kwargs): + if "cache_dashboard_screenshot" in endpoint: + return {"result": {"image_url": "/api/v1/dashboard/42/thumbnail/digest-1/"}} + if "raw_response" in kwargs and kwargs["raw_response"]: + mock_resp = MagicMock() + mock_resp.status_code = 202 + mock_resp.headers = {"Content-Type": "application/json"} + mock_resp.json.return_value = {"message": "Thumbnail is being generated"} + return mock_resp + return {} + + mock_async_client.request.side_effect = _fake_request_202 + + from src.dependencies import get_config_manager + + with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \ + patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client): + client = _make_client({get_config_manager: lambda: mock_config}) + resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1&force=true") + assert resp.status_code == 202 + + def test_thumbnail_dashboard_not_found_404(self): + """DashboardNotFoundError returns 404.""" + from src.core.utils.network import DashboardNotFoundError + mock_config = MagicMock() + mock_config.get_environments.return_value = [_make_mock_env(id="env-1")] + + mock_client = AsyncMock() + mock_client.get_dashboards_page.return_value = (1, [{"id": 42}]) + + mock_async_client = AsyncMock() + mock_async_client.request.side_effect = DashboardNotFoundError("Dashboard not found") + + from src.dependencies import get_config_manager + + with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \ + patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client): + client = _make_client({get_config_manager: lambda: mock_config}) + resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1") + assert resp.status_code == 404 + assert "Dashboard thumbnail not found" in resp.text + + def test_thumbnail_fallback_to_dashboard_url(self): + """Fallback when cache_dashboard_screenshot is unavailable.""" + mock_config = MagicMock() + mock_config.get_environments.return_value = [_make_mock_env(id="env-1")] + + mock_client = AsyncMock() + mock_client.get_dashboards_page.return_value = (1, [{"id": 42}]) + + mock_async_client = AsyncMock() + + async def _fake_fallback_request(method, endpoint, **kwargs): + if "cache_dashboard_screenshot" in endpoint: + return {} # No image_url in response + if endpoint == "/dashboard/42": + return {"result": {"thumbnail_url": "/api/v1/dashboard/42/thumbnail/fallback-digest/"}} + if "raw_response" in kwargs and kwargs["raw_response"]: + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.headers = {"Content-Type": "image/png"} + mock_resp.content = b"fallback-image" + return mock_resp + return {} + + mock_async_client.request.side_effect = _fake_fallback_request + + from src.dependencies import get_config_manager + + with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \ + patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client): + client = _make_client({get_config_manager: lambda: mock_config}) + resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1") + assert resp.status_code == 200 + assert resp.content == b"fallback-image" + + def test_thumbnail_generic_error_503(self): + """Generic exception returns 503.""" + mock_config = MagicMock() + mock_config.get_environments.return_value = [_make_mock_env(id="env-1")] + + mock_client = AsyncMock() + mock_client.get_dashboards_page.return_value = (1, [{"id": 42}]) + + mock_async_client = AsyncMock() + mock_async_client.request.side_effect = RuntimeError("Unexpected error") + + from src.dependencies import get_config_manager + + with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \ + patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client): + client = _make_client({get_config_manager: lambda: mock_config}) + resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1") + assert resp.status_code == 503 + assert "Failed to fetch dashboard thumbnail" in resp.text # #endregion Test.Api.DashboardDetailRoutes diff --git a/backend/tests/api/test_dashboard_listing_routes.py b/backend/tests/api/test_dashboard_listing_routes.py index 56e9dda1..005be8b4 100644 --- a/backend/tests/api/test_dashboard_listing_routes.py +++ b/backend/tests/api/test_dashboard_listing_routes.py @@ -265,4 +265,44 @@ class TestGetDashboards: assert data["total"] == 2 assert data["total_pages"] == 2 assert len(data["dashboards"]) == 1 + + def test_get_dashboards_llm_filter(self, base_mocks): + """LLM status column filter applied.""" + mock_rs = base_mocks["resource_service"] + mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support") + mock_rs.get_dashboards_with_status.return_value = [ + {"id": 1, "title": "Main", "slug": "main", "owners": [], + "last_task": {"validation_status": "PASS"}, "git_status": {"sync_status": "OK", "has_repo": True}}, + {"id": 2, "title": "Other", "slug": "other", "owners": [], + "last_task": {"validation_status": "FAIL"}, "git_status": {"sync_status": "OK", "has_repo": True}}, + ] + + client = _make_client(base_mocks) + resp = client.get("/api/dashboards?env_id=env-1&filter_llm_status=pass") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + + def test_get_dashboards_503_on_full_scan_fail(self, base_mocks): + """When both page and full scan fail, returns 503.""" + mock_rs = base_mocks["resource_service"] + mock_rs.get_dashboards_page_with_status.side_effect = Exception("Page fail") + mock_rs.get_dashboards_with_status.side_effect = Exception("Full scan fail") + + client = _make_client(base_mocks) + resp = client.get("/api/dashboards?env_id=env-1&filter_git_status=ok") + assert resp.status_code == 503 + assert "Failed to fetch dashboards" in resp.text + + def test_get_dashboards_override_show_all(self, base_mocks): + """override_show_all disables profile filter.""" + mock_rs = base_mocks["resource_service"] + mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support") + mock_rs.get_dashboards_with_status.return_value = [ + {"id": 1, "title": "Main", "slug": "main", "owners": ["admin"]}, + ] + + client = _make_client(base_mocks) + resp = client.get("/api/dashboards?env_id=env-1&override_show_all=true") + assert resp.status_code == 200 # #endregion Test.Api.DashboardListingRoutes diff --git a/backend/tests/api/test_profile_routes.py b/backend/tests/api/test_profile_routes.py new file mode 100644 index 00000000..19ff5731 --- /dev/null +++ b/backend/tests/api/test_profile_routes.py @@ -0,0 +1,198 @@ +# #region Test.Api.ProfileRoutes [C:3] [TYPE Module] [SEMANTICS test,profile,api,preferences,superset] +# @BRIEF Tests for profile.py — preferences CRUD and Superset account lookup. +# @RELATION BINDS_TO -> [ProfileApiModule] +# @TEST_EDGE: update_validation_error -> 422 +# @TEST_EDGE: update_authorization_error -> 403 +# @TEST_EDGE: superset_env_not_found -> 404 + +import os +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +def _make_client(overrides: dict | None = None) -> TestClient: + from src.api.routes.profile import router + from src.dependencies import get_current_user, get_db, get_config_manager, get_plugin_loader + from src.schemas.auth import User, RoleSchema + + app = FastAPI() + app.include_router(router) + + mock_user = User( + id="user-1", username="testuser", email="test@example.com", + auth_source="LOCAL", + created_at=__import__("datetime").datetime.now(), + roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])], + ) + + defaults = { + get_current_user: lambda: mock_user, + get_db: lambda: MagicMock(), + get_config_manager: lambda: MagicMock(), + get_plugin_loader: lambda: None, + } + if overrides: + defaults.update(overrides) + for dep, mock_fn in defaults.items(): + app.dependency_overrides[dep] = mock_fn + return TestClient(app) + + +# ── get_preferences ── + +class TestGetPreferences: + """GET /api/profile/preferences""" + + def test_get_preferences_success(self): + from datetime import datetime, UTC + with patch("src.api.routes.profile.ProfileService") as MockService: + instance = MockService.return_value + instance.get_my_preference.return_value = { + "status": "success", + "preference": { + "user_id": "user-1", + "show_only_my_dashboards": True, + "show_only_slug_dashboards": True, + "superset_username": "admin", + "created_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), + }, + "security": {"read_only": False}, + } + client = _make_client() + resp = client.get("/api/profile/preferences") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "success" + assert data["preference"]["show_only_my_dashboards"] is True + + +# ── update_preferences ── + +class TestUpdatePreferences: + """PATCH /api/profile/preferences""" + + def test_update_success(self): + from datetime import datetime, UTC + with patch("src.api.routes.profile.ProfileService") as MockService: + instance = MockService.return_value + instance.update_my_preference.return_value = { + "status": "success", + "preference": { + "user_id": "user-1", + "show_only_my_dashboards": False, + "show_only_slug_dashboards": True, + "created_at": datetime.now(UTC).isoformat(), + "updated_at": datetime.now(UTC).isoformat(), + }, + "security": {"read_only": False}, + } + client = _make_client() + resp = client.patch("/api/profile/preferences", json={ + "show_only_my_dashboards": False, + }) + assert resp.status_code == 200 + data = resp.json() + assert data["preference"]["show_only_my_dashboards"] is False + + def test_update_validation_error_422(self): + from src.services.profile_service import ProfileValidationError + with patch("src.api.routes.profile.ProfileService") as MockService: + instance = MockService.return_value + instance.update_my_preference.side_effect = ProfileValidationError( + [{"field": "show_only_my_dashboards", "message": "Invalid value"}] + ) + client = _make_client() + resp = client.patch("/api/profile/preferences", json={ + "show_only_my_dashboards": "not-a-boolean", + }) + assert resp.status_code == 422 + + def test_update_authorization_error_403(self): + from src.services.profile_service import ProfileAuthorizationError + with patch("src.api.routes.profile.ProfileService") as MockService: + instance = MockService.return_value + instance.update_my_preference.side_effect = ProfileAuthorizationError( + "Cross-user mutation blocked" + ) + client = _make_client() + resp = client.patch("/api/profile/preferences", json={ + "show_only_my_dashboards": True, + }) + assert resp.status_code == 403 + assert "Cross-user mutation" in resp.text + + +# ── lookup_superset_accounts ── + +class TestLookupSupersetAccounts: + """GET /api/profile/superset-accounts""" + + def test_lookup_success(self): + with patch("src.api.routes.profile.ProfileService") as MockService: + instance = MockService.return_value + instance.lookup_superset_accounts = AsyncMock(return_value={ + "status": "success", + "environment_id": "env-1", + "page_index": 0, + "page_size": 20, + "total": 1, + "items": [{"environment_id": "env-1", "username": "admin"}], + }) + client = _make_client() + resp = client.get("/api/profile/superset-accounts?environment_id=env-1") + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) == 1 + + def test_lookup_with_search_and_pagination(self): + with patch("src.api.routes.profile.ProfileService") as MockService: + instance = MockService.return_value + instance.lookup_superset_accounts = AsyncMock(return_value={ + "status": "success", + "environment_id": "env-1", + "page_index": 0, + "page_size": 10, + "total": 1, + "items": [{"environment_id": "env-1", "username": "analyst"}], + }) + client = _make_client() + resp = client.get( + "/api/profile/superset-accounts?environment_id=env-1" + "&search=analyst&page_index=0&page_size=10" + "&sort_column=username&sort_order=asc" + ) + assert resp.status_code == 200 + data = resp.json() + assert data["items"][0]["username"] == "analyst" + + def test_lookup_env_not_found_404(self): + from src.services.profile_service import EnvironmentNotFoundError + with patch("src.api.routes.profile.ProfileService") as MockService: + instance = MockService.return_value + instance.lookup_superset_accounts = AsyncMock( + side_effect=EnvironmentNotFoundError("Environment not found: env-ghost") + ) + client = _make_client() + resp = client.get("/api/profile/superset-accounts?environment_id=env-ghost") + assert resp.status_code == 404 + + def test_lookup_missing_environment_id_422(self): + # Required Query parameter environment_id missing -> FastAPI 422 + client = _make_client() + resp = client.get("/api/profile/superset-accounts") + assert resp.status_code == 422 +# #endregion Test.Api.ProfileRoutes diff --git a/backend/tests/api/test_reports_routes.py b/backend/tests/api/test_reports_routes.py new file mode 100644 index 00000000..907b175c --- /dev/null +++ b/backend/tests/api/test_reports_routes.py @@ -0,0 +1,193 @@ +# #region Test.Api.ReportsRoutes [C:3] [TYPE Module] [SEMANTICS test,reports,api] +# @BRIEF Tests for reports.py — list reports and get report detail. +# @RELATION BINDS_TO -> [ReportsRouter] +# @TEST_EDGE: parse_invalid_task_type -> 400 +# @TEST_EDGE: parse_invalid_status -> 400 +# @TEST_EDGE: general_query_exception -> 400 +# @TEST_EDGE: report_not_found -> 404 + +import os +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch, AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +def _make_client(overrides: dict | None = None) -> TestClient: + from src.api.routes.reports import router + from src.dependencies import get_clean_release_repository, get_task_manager, get_current_user, has_permission + from src.schemas.auth import User, RoleSchema + + app = FastAPI() + app.include_router(router) + + mock_user = User( + id="user-1", username="admin", email="admin@x.com", + auth_source="LOCAL", + created_at=__import__("datetime").datetime.now(), + roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])], + ) + + defaults = { + get_task_manager: lambda: MagicMock(), + get_clean_release_repository: lambda: MagicMock(), + get_current_user: lambda: mock_user, + has_permission: lambda *a, **kw: lambda: None, + } + if overrides: + defaults.update(overrides) + for dep, mock_fn in defaults.items(): + app.dependency_overrides[dep] = mock_fn + return TestClient(app) + + +# ── _parse_csv_enum_list ── + +class TestParseCsvEnumList: + """_parse_csv_enum_list — parse comma-separated values into enum list.""" + + def test_none_returns_empty(self): + from src.api.routes.reports import _parse_csv_enum_list + from src.models.report import TaskType + assert _parse_csv_enum_list(None, TaskType, "task_types") == [] + + def test_empty_string_returns_empty(self): + from src.api.routes.reports import _parse_csv_enum_list + from src.models.report import TaskType + assert _parse_csv_enum_list("", TaskType, "task_types") == [] + + def test_valid_values_parsed(self): + from src.api.routes.reports import _parse_csv_enum_list + from src.models.report import TaskType + result = _parse_csv_enum_list("backup, migration", TaskType, "task_types") + assert TaskType.BACKUP in result + assert TaskType.MIGRATION in result + + def test_invalid_values_raise_400(self): + from fastapi import HTTPException + from src.api.routes.reports import _parse_csv_enum_list + from src.models.report import TaskType + with pytest.raises(HTTPException) as exc: + _parse_csv_enum_list("INVALID_TYPE", TaskType, "task_types") + assert exc.value.status_code == 400 + + +# ── list_reports ── + +class TestListReports: + """GET /api/reports""" + + def test_list_success(self): + from src.models.report import ReportCollection, ReportQuery + with patch("src.api.routes.reports.ReportsService") as MockService: + instance = MockService.return_value + instance.list_reports.return_value = ReportCollection( + items=[], total=0, page=1, page_size=20, has_next=False, + applied_filters=ReportQuery(), + ) + client = _make_client() + resp = client.get("/api/reports?page=1&page_size=20") + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert "total" in data + + def test_list_with_filters(self): + from src.models.report import ReportCollection, ReportQuery + with patch("src.api.routes.reports.ReportsService") as MockService: + instance = MockService.return_value + instance.list_reports.return_value = ReportCollection( + items=[], total=0, page=1, page_size=20, has_next=False, + applied_filters=ReportQuery(search="test", sort_by="updated_at", sort_order="asc"), + ) + client = _make_client() + resp = client.get("/api/reports?page=1&page_size=20&search=test&sort_by=updated_at&sort_order=asc") + assert resp.status_code == 200 + + def test_list_invalid_task_type_400(self): + client = _make_client() + resp = client.get("/api/reports?task_types=INVALID") + assert resp.status_code == 400 + + def test_list_invalid_status_400(self): + client = _make_client() + resp = client.get("/api/reports?statuses=INVALID") + assert resp.status_code == 400 + + def test_list_service_exception_500(self): + with patch("src.api.routes.reports.ReportsService") as MockService: + instance = MockService.return_value + instance.list_reports.side_effect = ValueError("Internal error") + client = _make_client() + resp = client.get("/api/reports?page=1&page_size=20") + # Exception from service call is outside try-except -> 500 + assert resp.status_code == 500 + + def test_list_invalid_page_size_400(self): + mock_task_manager = MagicMock() + mock_clean_repo = MagicMock() + + from src.dependencies import get_task_manager, get_clean_release_repository + client = _make_client({ + get_task_manager: lambda: mock_task_manager, + get_clean_release_repository: lambda: mock_clean_repo, + }) + # page_size=0 is rejected by Query(ge=1) -> FastAPI 422 + resp = client.get("/api/reports?page_size=0") + assert resp.status_code == 422 + + +# ── get_report_detail ── + +class TestGetReportDetail: + """GET /api/reports/{report_id}""" + + def test_detail_success(self): + from src.models.report import ReportDetailView, TaskReport, ReportStatus, TaskType + with patch("src.api.routes.reports.ReportsService") as MockService: + instance = MockService.return_value + instance.get_report_detail.return_value = ReportDetailView( + task=TaskReport( + id="report-1", + task_type=TaskType.BACKUP, + status=ReportStatus.SUCCESS, + title="Backup report", + created_at=__import__("datetime").datetime.now(), + ), + diagnostics=[], + next_actions=[], + ) + client = _make_client() + resp = client.get("/api/reports/report-1") + assert resp.status_code == 200 + data = resp.json() + assert data["task"]["id"] == "report-1" + + def test_detail_not_found(self): + mock_task_manager = MagicMock() + mock_clean_repo = MagicMock() + + from src.dependencies import get_task_manager, get_clean_release_repository + client = _make_client({ + get_task_manager: lambda: mock_task_manager, + get_clean_release_repository: lambda: mock_clean_repo, + }) + + with patch("src.api.routes.reports.ReportsService") as MockService: + instance = MockService.return_value + instance.get_report_detail.return_value = None + resp = client.get("/api/reports/report-missing") + assert resp.status_code == 404 + assert "REPORT_NOT_FOUND" in resp.text +# #endregion Test.Api.ReportsRoutes diff --git a/backend/tests/api/test_translate_correction_routes.py b/backend/tests/api/test_translate_correction_routes.py new file mode 100644 index 00000000..d83b0e8a --- /dev/null +++ b/backend/tests/api/test_translate_correction_routes.py @@ -0,0 +1,161 @@ +# #region Test.Api.TranslateCorrectionRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,corrections,api] +# @BRIEF Tests for _correction_routes.py — term correction submission. +# @RELATION BINDS_TO -> [TranslateCorrectionRoutesModule] +# @TEST_EDGE: correction_missing_dict_id -> 422 +# @TEST_EDGE: correction_value_error -> 400 +# @TEST_EDGE: bulk_correction_value_error -> 400 +# @TEST_EDGE: bulk_correction_conflict -> 409 + +import os +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +def _make_client(overrides: dict | None = None) -> TestClient: + from src.api.routes.translate._correction_routes import router + from src.dependencies import get_current_user, get_db, has_permission, require_feature + from src.schemas.auth import User, RoleSchema + + app = FastAPI() + app.include_router(router) + + mock_user = User( + id="user-1", username="admin", email="admin@x.com", + auth_source="LOCAL", + created_at=__import__("datetime").datetime.now(), + roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])], + ) + + defaults = { + get_current_user: lambda: mock_user, + get_db: lambda: MagicMock(), + has_permission: lambda *a, **kw: lambda: None, + require_feature: lambda *a, **kw: lambda: None, + } + if overrides: + defaults.update(overrides) + for dep, mock_fn in defaults.items(): + app.dependency_overrides[dep] = mock_fn + return TestClient(app) + + +# ── submit_correction ── + +class TestSubmitCorrection: + """POST /api/translate/corrections""" + + def test_submit_success(self): + with patch("src.api.routes.translate._correction_routes.DictionaryManager") as MockDict: + MockDict.submit_correction.return_value = {"status": "applied", "entry_id": "entry-1"} + client = _make_client() + resp = client.post("/api/translate/corrections", json={ + "dictionary_id": "dict-1", + "source_term": "old term", + "incorrect_target_term": "wrong", + "corrected_target_term": "correct", + "origin_run_id": "run-1", + "origin_row_key": "row-1", + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "applied" + + def test_submit_missing_dict_id_422(self): + client = _make_client() + resp = client.post("/api/translate/corrections", json={ + "source_term": "term", + "incorrect_target_term": "wrong", + "corrected_target_term": "correct", + }) + assert resp.status_code == 422 + assert "dictionary_id" in resp.text + + def test_submit_value_error_400(self): + with patch("src.api.routes.translate._correction_routes.DictionaryManager") as MockDict: + MockDict.submit_correction.side_effect = ValueError("Invalid dictionary") + client = _make_client() + resp = client.post("/api/translate/corrections", json={ + "dictionary_id": "dict-bad", + "source_term": "term", + "incorrect_target_term": "wrong", + "corrected_target_term": "correct", + "origin_run_id": "run-1", + }) + assert resp.status_code == 400 + + +# ── submit_bulk_corrections ── + +class TestSubmitBulkCorrections: + """POST /api/translate/corrections/bulk""" + + def test_bulk_success(self): + with patch("src.api.routes.translate._correction_routes.DictionaryManager") as MockDict: + MockDict.submit_bulk_corrections.return_value = {"status": "applied", "count": 2} + client = _make_client() + resp = client.post("/api/translate/corrections/bulk", json={ + "dictionary_id": "dict-1", + "corrections": [ + { + "source_term": "term1", + "incorrect_target_term": "wrong1", + "corrected_target_term": "correct1", + "origin_run_id": "run-1", + "origin_row_key": "row-1", + }, + { + "source_term": "term2", + "incorrect_target_term": "wrong2", + "corrected_target_term": "correct2", + "origin_run_id": "run-1", + "origin_row_key": "row-2", + }, + ], + }) + assert resp.status_code == 200 + assert resp.json()["count"] == 2 + + def test_bulk_conflict_409(self): + with patch("src.api.routes.translate._correction_routes.DictionaryManager") as MockDict: + MockDict.submit_bulk_corrections.return_value = { + "status": "conflicts", + "conflicts": [{"source_term": "term1", "message": "Existing entry"}], + } + client = _make_client() + resp = client.post("/api/translate/corrections/bulk", json={ + "dictionary_id": "dict-1", + "corrections": [ + { + "source_term": "term1", + "incorrect_target_term": "wrong1", + "corrected_target_term": "correct1", + "origin_run_id": "run-1", + "origin_row_key": "row-1", + }, + ], + }) + assert resp.status_code == 409 + assert "conflicts" in resp.text + + def test_bulk_value_error_400(self): + with patch("src.api.routes.translate._correction_routes.DictionaryManager") as MockDict: + MockDict.submit_bulk_corrections.side_effect = ValueError("Invalid data") + client = _make_client() + resp = client.post("/api/translate/corrections/bulk", json={ + "dictionary_id": "dict-1", + "corrections": [], + }) + assert resp.status_code == 400 +# #endregion Test.Api.TranslateCorrectionRoutes diff --git a/backend/tests/api/test_translate_metrics_routes.py b/backend/tests/api/test_translate_metrics_routes.py new file mode 100644 index 00000000..bebdbe52 --- /dev/null +++ b/backend/tests/api/test_translate_metrics_routes.py @@ -0,0 +1,126 @@ +# #region Test.Api.TranslateMetricsRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,metrics,api] +# @BRIEF Tests for _metrics_routes.py — translation metrics endpoints. +# @RELATION BINDS_TO -> [TranslateMetricsRoutesModule] +# @TEST_EDGE: metrics_value_error -> 404 +# @TEST_EDGE: job_metrics_value_error -> 404 + +import os +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +def _make_client(overrides: dict | None = None) -> TestClient: + from src.api.routes.translate._metrics_routes import router + from src.dependencies import get_current_user, get_db, has_permission, require_feature + from src.schemas.auth import User, RoleSchema + + app = FastAPI() + app.include_router(router) + + mock_user = User( + id="user-1", username="admin", email="admin@x.com", + auth_source="LOCAL", + created_at=__import__("datetime").datetime.now(), + roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])], + ) + + defaults = { + get_current_user: lambda: mock_user, + get_db: lambda: MagicMock(), + has_permission: lambda *a, **kw: lambda: None, + require_feature: lambda *a, **kw: lambda: None, + } + if overrides: + defaults.update(overrides) + for dep, mock_fn in defaults.items(): + app.dependency_overrides[dep] = mock_fn + return TestClient(app) + + +# ── get_metrics ── + +class TestGetMetrics: + """GET /api/translate/metrics""" + + def test_get_all_metrics_success(self): + mock_metrics = MagicMock() + mock_metrics.get_all_metrics.return_value = { + "total_translations": 100, + "success_rate": 0.95, + } + + with patch("src.api.routes.translate._metrics_routes.TranslationMetrics", + return_value=mock_metrics): + client = _make_client() + resp = client.get("/api/translate/metrics") + assert resp.status_code == 200 + assert resp.json()["total_translations"] == 100 + + def test_get_job_metrics_with_job_id(self): + mock_metrics = MagicMock() + mock_metrics.get_job_metrics.return_value = { + "job_id": "job-1", + "total_translations": 50, + "success_rate": 0.90, + } + + with patch("src.api.routes.translate._metrics_routes.TranslationMetrics", + return_value=mock_metrics): + client = _make_client() + resp = client.get("/api/translate/metrics?job_id=job-1") + assert resp.status_code == 200 + assert resp.json()["job_id"] == "job-1" + + def test_get_metrics_not_found_404(self): + mock_metrics = MagicMock() + mock_metrics.get_all_metrics.side_effect = ValueError("No metrics found") + + with patch("src.api.routes.translate._metrics_routes.TranslationMetrics", + return_value=mock_metrics): + client = _make_client() + resp = client.get("/api/translate/metrics") + assert resp.status_code == 404 + + +# ── get_job_metrics ── + +class TestGetJobMetrics: + """GET /api/translate/jobs/{job_id}/metrics""" + + def test_job_metrics_success(self): + mock_metrics = MagicMock() + mock_metrics.get_job_metrics.return_value = { + "job_id": "job-1", + "total_translations": 25, + } + + with patch("src.api.routes.translate._metrics_routes.TranslationMetrics", + return_value=mock_metrics): + client = _make_client() + resp = client.get("/api/translate/jobs/job-1/metrics") + assert resp.status_code == 200 + assert resp.json()["job_id"] == "job-1" + + def test_job_metrics_not_found_404(self): + mock_metrics = MagicMock() + mock_metrics.get_job_metrics.side_effect = ValueError("Job not found") + + with patch("src.api.routes.translate._metrics_routes.TranslationMetrics", + return_value=mock_metrics): + client = _make_client() + resp = client.get("/api/translate/jobs/job-missing/metrics") + assert resp.status_code == 404 +# #endregion Test.Api.TranslateMetricsRoutes diff --git a/backend/tests/api/test_translate_preview_routes.py b/backend/tests/api/test_translate_preview_routes.py new file mode 100644 index 00000000..078e3888 --- /dev/null +++ b/backend/tests/api/test_translate_preview_routes.py @@ -0,0 +1,165 @@ +# #region Test.Api.TranslatePreviewRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,preview,api] +# @BRIEF Tests for _preview_routes.py — preview translation and preview records. +# @RELATION BINDS_TO -> [TranslatePreviewRoutesModule] +# @TEST_EDGE: preview_validation_error -> 400 +# @TEST_EDGE: preview_service_error -> 502 +# @TEST_EDGE: preview_session_not_found -> 404 + +import os +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +def _make_client(overrides: dict | None = None) -> TestClient: + from src.api.routes.translate._preview_routes import router + from src.dependencies import get_current_user, get_config_manager, get_db, has_permission, require_feature + from src.schemas.auth import User, RoleSchema + + app = FastAPI() + app.include_router(router) + + mock_user = User( + id="user-1", username="admin", email="admin@x.com", + auth_source="LOCAL", + created_at=__import__("datetime").datetime.now(), + roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])], + ) + + defaults = { + get_current_user: lambda: mock_user, + get_config_manager: lambda: MagicMock(), + get_db: lambda: MagicMock(), + has_permission: lambda *a, **kw: lambda: None, + require_feature: lambda *a, **kw: lambda: None, + } + if overrides: + defaults.update(overrides) + for dep, mock_fn in defaults.items(): + app.dependency_overrides[dep] = mock_fn + return TestClient(app) + + +# ── preview_translation ── + +class TestPreviewTranslation: + """POST /api/translate/jobs/{job_id}/preview""" + + def test_preview_success(self): + mock_preview = AsyncMock() + mock_preview.preview_rows.return_value = { + "session_id": "preview-1", + "rows": [{"id": "row-1", "source_sql": "SELECT 1"}], + "cost_estimation": {"total_tokens": 100}, + } + + with patch("src.api.routes.translate._preview_routes.TranslationPreview", + return_value=mock_preview): + client = _make_client() + resp = client.post("/api/translate/jobs/job-1/preview", json={ + "sample_size": 5, + }) + assert resp.status_code == 201 + data = resp.json() + assert data["session_id"] == "preview-1" + + def test_preview_no_payload_defaults(self): + """When payload is None, PreviewRequest() defaults are used.""" + mock_preview = AsyncMock() + mock_preview.preview_rows.return_value = { + "session_id": "preview-2", + "rows": [], + "cost_estimation": {"total_tokens": 0}, + } + + with patch("src.api.routes.translate._preview_routes.TranslationPreview", + return_value=mock_preview): + client = _make_client() + resp = client.post("/api/translate/jobs/job-1/preview") + assert resp.status_code == 201 + + def test_preview_validation_error_400(self): + mock_preview = AsyncMock() + mock_preview.preview_rows.side_effect = ValueError("Invalid job_id") + + with patch("src.api.routes.translate._preview_routes.TranslationPreview", + return_value=mock_preview): + client = _make_client() + resp = client.post("/api/translate/jobs/job-1/preview", json={}) + assert resp.status_code == 400 + assert "Invalid job_id" in resp.text + + def test_preview_service_error_502(self): + mock_preview = AsyncMock() + mock_preview.preview_rows.side_effect = RuntimeError("LLM provider failed") + + with patch("src.api.routes.translate._preview_routes.TranslationPreview", + return_value=mock_preview): + client = _make_client() + resp = client.post("/api/translate/jobs/job-1/preview", json={}) + assert resp.status_code == 502 + assert "Preview failed" in resp.text + + +# ── get_preview_records ── + +class TestGetPreviewRecords: + """GET /api/translate/preview/{session_id}/records""" + + def test_records_success(self): + from src.schemas.translate import PreviewRow + mock_db = MagicMock() + mock_session = MagicMock() + mock_session.id = "preview-1" + mock_record = MagicMock() + mock_record.id = "rec-1" + mock_record.source_sql = "SELECT 1" + mock_record.target_sql = "SELECT 1" + mock_record.source_object_type = "table" + mock_record.source_object_id = "obj-1" + mock_record.source_object_name = "test" + mock_record.status = "pending" + mock_record.feedback = None + + mock_db.query.return_value.filter.return_value.first.return_value = mock_session + mock_db.query.return_value.filter.return_value.all.return_value = [mock_record] + + from src.dependencies import get_db + client = _make_client({get_db: lambda: mock_db}) + resp = client.get("/api/translate/preview/preview-1/records") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["id"] == "rec-1" + + def test_records_session_not_found_404(self): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.return_value = None + + from src.dependencies import get_db + client = _make_client({get_db: lambda: mock_db}) + resp = client.get("/api/translate/preview/preview-missing/records") + assert resp.status_code == 404 + assert "Preview session" in resp.text + + def test_records_db_error_400(self): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.first.side_effect = Exception("DB error") + + from src.dependencies import get_db + client = _make_client({get_db: lambda: mock_db}) + resp = client.get("/api/translate/preview/preview-1/records") + assert resp.status_code == 400 +# #endregion Test.Api.TranslatePreviewRoutes diff --git a/backend/tests/api/test_translate_run_history_routes.py b/backend/tests/api/test_translate_run_history_routes.py new file mode 100644 index 00000000..96f6cca9 --- /dev/null +++ b/backend/tests/api/test_translate_run_history_routes.py @@ -0,0 +1,213 @@ +# #region Test.Api.TranslateRunHistoryRoutes [C:3] [TYPE Module] [SEMANTICS test,translate,history,runs,batches,api] +# @BRIEF Tests for _run_history_routes.py — run history, status, records, batches. +# @RELATION BINDS_TO -> [TranslateRunHistoryRoutesModule] +# @TEST_EDGE: run_history_value_error -> 404 +# @TEST_EDGE: run_status_value_error -> 404 +# @TEST_EDGE: run_records_value_error -> 404 +# @TEST_EDGE: batches_exception -> 400 + +import os +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +_src = str(Path(__file__).resolve().parent.parent.parent / "src") +if _src not in sys.path: + sys.path.insert(0, _src) + + +def _make_client(overrides: dict | None = None) -> TestClient: + from src.api.routes.translate._run_history_routes import router + from src.dependencies import get_current_user, get_config_manager, get_db, has_permission, require_feature + from src.schemas.auth import User, RoleSchema + + app = FastAPI() + app.include_router(router) + + mock_user = User( + id="user-1", username="admin", email="admin@x.com", + auth_source="LOCAL", + created_at=__import__("datetime").datetime.now(), + roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])], + ) + + defaults = { + get_current_user: lambda: mock_user, + get_config_manager: lambda: MagicMock(), + get_db: lambda: MagicMock(), + has_permission: lambda *a, **kw: lambda: None, + require_feature: lambda *a, **kw: lambda: None, + } + if overrides: + defaults.update(overrides) + for dep, mock_fn in defaults.items(): + app.dependency_overrides[dep] = mock_fn + return TestClient(app) + + +# ── get_run_history ── + +class TestGetRunHistory: + """GET /api/translate/jobs/{job_id}/runs""" + + def test_run_history_success(self): + mock_orch = MagicMock() + mock_orch.get_run_history.return_value = (1, [{"id": "run-1", "status": "completed"}]) + + with patch("src.api.routes.translate._run_history_routes.TranslationOrchestrator", + return_value=mock_orch): + client = _make_client() + resp = client.get("/api/translate/jobs/job-1/runs") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + assert data["page"] == 1 + + def test_run_history_not_found_404(self): + mock_orch = MagicMock() + mock_orch.get_run_history.side_effect = ValueError("Job not found") + + with patch("src.api.routes.translate._run_history_routes.TranslationOrchestrator", + return_value=mock_orch): + client = _make_client() + resp = client.get("/api/translate/jobs/job-missing/runs") + assert resp.status_code == 404 + + +# ── get_run_status ── + +class TestGetRunStatus: + """GET /api/translate/runs/{run_id}""" + + def test_run_status_success(self): + mock_orch = MagicMock() + mock_orch.get_run_status.return_value = {"id": "run-1", "status": "running"} + + with patch("src.api.routes.translate._run_history_routes.TranslationOrchestrator", + return_value=mock_orch): + client = _make_client() + resp = client.get("/api/translate/runs/run-1") + assert resp.status_code == 200 + assert resp.json()["status"] == "running" + + def test_run_status_not_found_404(self): + mock_orch = MagicMock() + mock_orch.get_run_status.side_effect = ValueError("Run not found") + + with patch("src.api.routes.translate._run_history_routes.TranslationOrchestrator", + return_value=mock_orch): + client = _make_client() + resp = client.get("/api/translate/runs/run-missing") + assert resp.status_code == 404 + + +# ── get_run_records ── + +class TestGetRunRecords: + """GET /api/translate/runs/{run_id}/records""" + + def test_run_records_success(self): + mock_orch = MagicMock() + mock_orch.get_run_records.return_value = { + "items": [{"id": "rec-1"}], + "total": 1, + "page": 1, + "page_size": 50, + } + + with patch("src.api.routes.translate._run_history_routes.TranslationOrchestrator", + return_value=mock_orch): + client = _make_client() + resp = client.get("/api/translate/runs/run-1/records") + assert resp.status_code == 200 + assert resp.json()["total"] == 1 + + def test_run_records_with_filters(self): + mock_orch = MagicMock() + mock_orch.get_run_records.return_value = { + "items": [], + "total": 0, + "page": 1, + "page_size": 50, + } + + with patch("src.api.routes.translate._run_history_routes.TranslationOrchestrator", + return_value=mock_orch): + client = _make_client() + resp = client.get("/api/translate/runs/run-1/records?status=failed&deduplicate=true") + assert resp.status_code == 200 + mock_orch.get_run_records.assert_called_once_with( + "run-1", page=1, page_size=50, status_filter="failed", deduplicate=True + ) + + def test_run_records_not_found_404(self): + mock_orch = MagicMock() + mock_orch.get_run_records.side_effect = ValueError("Run not found") + + with patch("src.api.routes.translate._run_history_routes.TranslationOrchestrator", + return_value=mock_orch): + client = _make_client() + resp = client.get("/api/translate/runs/run-missing/records") + assert resp.status_code == 404 + + +# ── get_batches ── + +class TestGetBatches: + """GET /api/translate/runs/{run_id}/batches""" + + def test_batches_success(self): + from datetime import datetime, UTC + mock_db = MagicMock() + mock_batch = MagicMock() + mock_batch.id = "batch-1" + mock_batch.run_id = "run-1" + mock_batch.batch_index = 0 + mock_batch.status = "completed" + mock_batch.total_records = 10 + mock_batch.successful_records = 9 + mock_batch.failed_records = 1 + mock_batch.started_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + mock_batch.completed_at = datetime(2026, 1, 1, 12, 5, 0, tzinfo=UTC) + mock_batch.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC) + + mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [mock_batch] + + from src.dependencies import get_db + client = _make_client({get_db: lambda: mock_db}) + resp = client.get("/api/translate/runs/run-1/batches") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["id"] == "batch-1" + assert data[0]["status"] == "completed" + assert data[0]["batch_index"] == 0 + + def test_batches_empty(self): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] + + from src.dependencies import get_db + client = _make_client({get_db: lambda: mock_db}) + resp = client.get("/api/translate/runs/run-1/batches") + assert resp.status_code == 200 + assert resp.json() == [] + + def test_batches_db_error_400(self): + mock_db = MagicMock() + mock_db.query.return_value.filter.return_value.order_by.return_value.all.side_effect = Exception("DB error") + + from src.dependencies import get_db + client = _make_client({get_db: lambda: mock_db}) + resp = client.get("/api/translate/runs/run-1/batches") + assert resp.status_code == 400 +# #endregion Test.Api.TranslateRunHistoryRoutes diff --git a/backend/tests/plugins/translate/test_dictionary_validation.py b/backend/tests/plugins/translate/test_dictionary_validation.py new file mode 100644 index 00000000..c7dacf12 --- /dev/null +++ b/backend/tests/plugins/translate/test_dictionary_validation.py @@ -0,0 +1,75 @@ +# #region Test.DictionaryValidation [C:2] [TYPE Module] [SEMANTICS test, translate, validation, bcp47] +# @BRIEF Tests for dictionary_validation.py — _validate_bcp47. +# @RELATION BINDS_TO -> [_validate_bcp47] +# @TEST_CONTRACT: _validate_bcp47 -> None | raises ValueError on invalid tag +# @TEST_EDGE: empty_tag -> ValueError +# @TEST_EDGE: invalid_format -> ValueError +# @TEST_EDGE: valid_format -> None + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +import pytest + +from src.plugins.translate.dictionary_validation import _validate_bcp47 + + +class TestValidateBcp47: + """_validate_bcp47 — BCP-47 language tag validation.""" + + def test_valid_basic_tag(self): + """Simple language code like 'en' passes.""" + _validate_bcp47("en", "language") # no error + + def test_valid_region_tag(self): + """Tag with region like 'zh-CN' passes.""" + _validate_bcp47("zh-CN", "language") + + def test_valid_script_tag(self): + """Tag with script like 'zh-Hans' passes.""" + _validate_bcp47("zh-Hans", "language") + + def test_valid_multi_subtag(self): + """Complex tag like 'sr-Latn-RS' passes.""" + _validate_bcp47("sr-Latn-RS", "language") + + def test_empty_string_raises(self): + """Empty tag raises ValueError.""" + with pytest.raises(ValueError, match="must be a non-empty"): + _validate_bcp47("", "language") + + def test_whitespace_only_raises(self): + """Whitespace-only tag raises ValueError.""" + with pytest.raises(ValueError, match="must be a non-empty"): + _validate_bcp47(" ", "language") + + def test_none_raises(self): + """None tag raises ValueError.""" + with pytest.raises(ValueError, match="must be a non-empty"): + _validate_bcp47(None, "language") # type: ignore[arg-type] + + def test_invalid_format_raises(self): + """Tag with invalid characters raises ValueError.""" + with pytest.raises(ValueError, match="not a valid BCP-47"): + _validate_bcp47("en@utf8", "language") + + def test_invalid_numeric_start_raises(self): + """Tag starting with a number raises ValueError.""" + with pytest.raises(ValueError, match="not a valid BCP-47"): + _validate_bcp47("123", "language") + + def test_error_message_includes_field_name(self): + """Error message includes the field_name parameter.""" + with pytest.raises(ValueError, match="source_language"): + _validate_bcp47("", "source_language") + + def test_strips_whitespace(self): + """Whitespace around tag is stripped.""" + _validate_bcp47(" en ", "language") # no error after strip +# #endregion Test.DictionaryValidation diff --git a/backend/tests/plugins/translate/test_metrics.py b/backend/tests/plugins/translate/test_metrics.py new file mode 100644 index 00000000..91ae78d9 --- /dev/null +++ b/backend/tests/plugins/translate/test_metrics.py @@ -0,0 +1,275 @@ +# #region Test.Metrics [C:3] [TYPE Module] [SEMANTICS test, translate, metrics, job, statistics] +# @BRIEF Tests for metrics.py — TranslationMetrics.get_job_metrics, get_all_metrics. +# @RELATION BINDS_TO -> [TranslationMetrics] +# @TEST_CONTRACT: get_job_metrics -> dict | aggregated job metrics +# @TEST_CONTRACT: get_all_metrics -> list[dict] | aggregated metrics for all jobs +# @TEST_EDGE: no_runs +# @TEST_EDGE: per_language_aggregation +# @TEST_EDGE: metric_snapshot_merge + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from src.plugins.translate.metrics import TranslationMetrics + + +class _MockDB: + """Helper to create a DB mock with independent query mocks per call.""" + + @staticmethod + def create(query_count: int = 8): + """Create db mock where each db.query() call returns an independent mock.""" + db = MagicMock() + query_mocks = [MagicMock() for _ in range(query_count)] + db.query.side_effect = query_mocks + return db, query_mocks + + +class TestGetJobMetrics: + """TranslationMetrics.get_job_metrics — aggregated job metrics.""" + + def _make_lang_stat(self, code: str, **kwargs): + ls = MagicMock() + ls.language_code = code + ls.token_count = 1000 + ls.estimated_cost = 0.002 + ls.translated_rows = 50 + for k, v in kwargs.items(): + setattr(ls, k, v) + return ls + + def test_basic_metrics(self): + """Returns dict with all required keys.""" + db, q = _MockDB.create() + + # 0: run_counts — group_by().all() + q[0].filter.return_value.group_by.return_value.all.return_value = [ + ("COMPLETED", 5), ("FAILED", 2), + ] + # 1: record_stats — first() + q[1].filter.return_value.first.return_value = (500, 480, 10, 10) + # 2: avg_duration — filter(cond1, cond2, cond3).scalar() — single filter with 3 args + q[2].filter.return_value.scalar.return_value = 120000.0 + # 3: last_run — filter().order_by().first() + last_run = MagicMock() + last_run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + q[3].filter.return_value.order_by.return_value.first.return_value = last_run + # 4: next_schedule — filter().first() + next_schedule = MagicMock() + next_schedule.last_run_at = datetime(2026, 1, 20, 12, 0, 0, tzinfo=timezone.utc) + q[4].filter.return_value.first.return_value = next_schedule + # 5: latest_snapshot (1st call) — filter().order_by().first() + q[5].filter.return_value.order_by.return_value.first.return_value = None + # 6: lang_stats_rows — join().filter().all() + lang_stats = [self._make_lang_stat("ru"), self._make_lang_stat("de")] + q[6].join.return_value.filter.return_value.all.return_value = lang_stats + # 7: latest_snapshot (2nd call, same query) — filter().order_by().first() + q[7].filter.return_value.order_by.return_value.first.return_value = None + + metrics = TranslationMetrics(db) + result = metrics.get_job_metrics("job-1") + + assert result["job_id"] == "job-1" + assert result["total_runs"] == 7 + assert result["successful_runs"] == 5 + assert result["failed_runs"] == 2 + assert result["cancelled_runs"] == 0 + assert result["total_records"] == 500 + assert result["successful_records"] == 480 + assert result["failed_records"] == 10 + assert result["skipped_records"] == 10 + assert result["cumulative_tokens"] == 2000 + assert result["cumulative_cost"] == 0.004 + assert result["avg_duration_ms"] == 120000 + assert result["last_run_at"] is not None + assert result["next_scheduled_run"] is not None + assert len(result["per_language_metrics"]) == 2 + assert "ru" in result["per_language_metrics"] + assert "de" in result["per_language_metrics"] + + def test_no_runs(self): + """No runs returns zero metrics.""" + db, q = _MockDB.create() + + q[0].filter.return_value.group_by.return_value.all.return_value = [] + q[1].filter.return_value.first.return_value = (0, 0, 0, 0) + q[2].filter.return_value.scalar.return_value = None + q[3].filter.return_value.order_by.return_value.first.return_value = None + q[4].filter.return_value.first.return_value = None + q[5].filter.return_value.order_by.return_value.first.return_value = None + q[6].join.return_value.filter.return_value.all.return_value = [] + q[7].filter.return_value.order_by.return_value.first.return_value = None + + metrics = TranslationMetrics(db) + result = metrics.get_job_metrics("job-1") + + assert result["total_runs"] == 0 + assert result["total_records"] == 0 + assert result["avg_duration_ms"] is None + assert result["last_run_at"] is None + assert result["next_scheduled_run"] is None + assert result["per_language_metrics"] == {} + + def test_avg_duration_none_when_no_timed_runs(self): + """No completed runs => avg_duration_ms is None.""" + db, q = _MockDB.create() + + q[0].filter.return_value.group_by.return_value.all.return_value = [("PENDING", 1)] + q[1].filter.return_value.first.return_value = (0, 0, 0, 0) + q[2].filter.return_value.scalar.return_value = None + q[3].filter.return_value.order_by.return_value.first.return_value = None + q[4].filter.return_value.first.return_value = None + q[5].filter.return_value.order_by.return_value.first.return_value = None + q[6].join.return_value.filter.return_value.all.return_value = [] + q[7].filter.return_value.order_by.return_value.first.return_value = None + + metrics = TranslationMetrics(db) + result = metrics.get_job_metrics("job-1") + + assert result["avg_duration_ms"] is None + + def test_no_next_schedule(self): + """No active schedule => next_scheduled_run is None.""" + db, q = _MockDB.create() + + q[0].filter.return_value.group_by.return_value.all.return_value = [] + q[1].filter.return_value.first.return_value = (0, 0, 0, 0) + q[2].filter.return_value.scalar.return_value = None + q[3].filter.return_value.order_by.return_value.first.return_value = None + q[4].filter.return_value.first.return_value = None + q[5].filter.return_value.order_by.return_value.first.return_value = None + q[6].join.return_value.filter.return_value.all.return_value = [] + q[7].filter.return_value.order_by.return_value.first.return_value = None + + metrics = TranslationMetrics(db) + result = metrics.get_job_metrics("job-1") + + assert result["next_scheduled_run"] is None + + def test_per_language_with_snapshot_merge(self): + """MetricSnapshot per_language_metrics merged into result.""" + db, q = _MockDB.create() + + q[0].filter.return_value.group_by.return_value.all.return_value = [("COMPLETED", 3)] + q[1].filter.return_value.first.return_value = (100, 95, 3, 2) + q[2].filter.return_value.scalar.return_value = 50000.0 + q[3].filter.return_value.order_by.return_value.first.return_value = None # no last_run + q[4].filter.return_value.first.return_value = None # no next_schedule + # 5: latest_snapshot (1st) + snapshot1 = MagicMock() + snapshot1.per_language_metrics = None + q[5].filter.return_value.order_by.return_value.first.return_value = snapshot1 + # 6: lang_stats_rows (empty) + q[6].join.return_value.filter.return_value.all.return_value = [] + # 7: latest_snapshot (2nd) with per_language_metrics + snapshot2 = MagicMock() + snapshot2.per_language_metrics = { + "ru": {"cumulative_tokens": 5000, "cumulative_cost": 0.01, "runs": 10}, + } + q[7].filter.return_value.order_by.return_value.first.return_value = snapshot2 + + metrics = TranslationMetrics(db) + result = metrics.get_job_metrics("job-1") + + assert "ru" in result["per_language_metrics"] + assert result["cumulative_tokens"] == 5000 + assert result["cumulative_cost"] == 0.01 + + def test_per_language_with_live_and_snapshot(self): + """Live stats + snapshot metrics merge correctly.""" + db, q = _MockDB.create() + + q[0].filter.return_value.group_by.return_value.all.return_value = [("COMPLETED", 3)] + q[1].filter.return_value.first.return_value = (100, 95, 3, 2) + q[2].filter.return_value.scalar.return_value = 50000.0 + q[3].filter.return_value.order_by.return_value.first.return_value = None # no last_run + q[4].filter.return_value.first.return_value = None # no next_schedule + # 5: latest_snapshot (1st) + q[5].filter.return_value.order_by.return_value.first.return_value = None + # 6: lang_stats_rows — live data + q[6].join.return_value.filter.return_value.all.return_value = [ + self._make_lang_stat("ru", token_count=500, estimated_cost=0.001, translated_rows=25), + self._make_lang_stat("fr", token_count=200, estimated_cost=0.0005, translated_rows=10), + ] + # 7: latest_snapshot (2nd) + snapshot = MagicMock() + snapshot.per_language_metrics = { + "ru": {"cumulative_tokens": 5000, "cumulative_cost": 0.01, "runs": 10}, + "de": {"cumulative_tokens": 3000, "cumulative_cost": 0.006, "runs": 5}, + } + q[7].filter.return_value.order_by.return_value.first.return_value = snapshot + + metrics = TranslationMetrics(db) + result = metrics.get_job_metrics("job-1") + + assert "ru" in result["per_language_metrics"] + assert "de" in result["per_language_metrics"] + assert "fr" in result["per_language_metrics"] + # ru: live 500 + snapshot 5000 = 5500 + assert result["per_language_metrics"]["ru"]["tokens"] == 5500 + assert result["per_language_metrics"]["ru"]["cost"] == 0.011 + assert result["per_language_metrics"]["fr"]["tokens"] == 200 + assert result["per_language_metrics"]["de"]["tokens"] == 3000 + + def test_no_last_run_returns_none(self): + """No last run => last_run_at is None.""" + db, q = _MockDB.create() + + q[0].filter.return_value.group_by.return_value.all.return_value = [] + q[1].filter.return_value.first.return_value = (0, 0, 0, 0) + q[2].filter.return_value.scalar.return_value = None + q[3].filter.return_value.order_by.return_value.first.return_value = None + q[4].filter.return_value.first.return_value = None + q[5].filter.return_value.order_by.return_value.first.return_value = None + q[6].join.return_value.filter.return_value.all.return_value = [] + q[7].filter.return_value.order_by.return_value.first.return_value = None + + metrics = TranslationMetrics(db) + result = metrics.get_job_metrics("job-1") + + assert result["last_run_at"] is None + + +class TestGetAllMetrics: + """TranslationMetrics.get_all_metrics — metrics for all jobs.""" + + def test_all_metrics(self): + """Returns list of per-job metrics.""" + db = MagicMock() + db.query.return_value.distinct.return_value.all.return_value = [ + ("job-1",), ("job-2",), + ] + + with patch.object(TranslationMetrics, "get_job_metrics") as mock_get: + mock_get.side_effect = [ + {"job_id": "job-1", "total_runs": 5}, + {"job_id": "job-2", "total_runs": 3}, + ] + metrics = TranslationMetrics(db) + result = metrics.get_all_metrics() + + assert len(result) == 2 + assert result[0]["job_id"] == "job-1" + assert result[1]["job_id"] == "job-2" + + def test_empty_jobs(self): + """No jobs returns empty list.""" + db = MagicMock() + db.query.return_value.distinct.return_value.all.return_value = [] + + metrics = TranslationMetrics(db) + result = metrics.get_all_metrics() + + assert result == [] +# #endregion Test.Metrics diff --git a/backend/tests/plugins/translate/test_orchestrator.py b/backend/tests/plugins/translate/test_orchestrator.py new file mode 100644 index 00000000..2f93c87f --- /dev/null +++ b/backend/tests/plugins/translate/test_orchestrator.py @@ -0,0 +1,212 @@ +# #region Test.Orchestrator [C:3] [TYPE Module] [SEMANTICS test, translate, orchestrator, lifecycle] +# @BRIEF Tests for orchestrator.py — TranslationOrchestrator delegates to sub-components. +# @RELATION BINDS_TO -> [TranslationOrchestrator] +# @TEST_CONTRACT: start_run -> TranslationRun | delegates to TranslationPlanner +# @TEST_CONTRACT: execute_run -> TranslationRun | delegates to TranslationStageRunner +# @TEST_CONTRACT: retry_failed_batches -> TranslationRun | delegates to TranslationStageRunner +# @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to TranslationStageRunner +# @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to TranslationStageRunner +# @TEST_CONTRACT: get_run_status -> dict | delegates to TranslationResultAggregator +# @TEST_CONTRACT: get_run_records -> dict | delegates to TranslationResultAggregator +# @TEST_CONTRACT: get_run_history -> tuple | delegates to TranslationResultAggregator +# @TEST_EDGE: sql_insert_service_backward_compat +# @TEST_EDGE: language_stats_updates + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from src.plugins.translate.orchestrator import TranslationOrchestrator + + +class TestTranslationOrchestrator: + """TranslationOrchestrator — delegates all operations to sub-components.""" + + def _make_orchestrator(self): + """Create orchestrator with mocked sub-components.""" + db = MagicMock() + config = MagicMock() + + with patch( + "src.plugins.translate.orchestrator.TranslationPlanner" + ) as MockPlanner, patch( + "src.plugins.translate.orchestrator.TranslationStageRunner" + ) as MockRunner, patch( + "src.plugins.translate.orchestrator.TranslationResultAggregator" + ) as MockAggregator: + orch = TranslationOrchestrator(db, config, "user") + return orch, db, config, MockPlanner, MockRunner, MockAggregator + + def test_start_run_delegates(self): + """start_run delegates to TranslationPlanner.""" + orch, _, _, MockPlanner, _, _ = self._make_orchestrator() + mock_run = MagicMock() + orch._planner = MockPlanner.return_value + orch._planner.plan_run.return_value = mock_run + + result = orch.start_run("job-1", is_scheduled=True, trigger_type="cron", full_translation=True) + + orch._planner.plan_run.assert_called_once_with( + job_id="job-1", is_scheduled=True, trigger_type="cron", full_translation=True, + ) + assert result == mock_run + + def test_start_run_minimal(self): + """start_run with only job_id uses defaults.""" + orch, _, _, MockPlanner, _, _ = self._make_orchestrator() + mock_run = MagicMock() + orch._planner = MockPlanner.return_value + orch._planner.plan_run.return_value = mock_run + + result = orch.start_run("job-1") + + orch._planner.plan_run.assert_called_once_with( + job_id="job-1", is_scheduled=False, trigger_type=None, full_translation=False, + ) + + @pytest.mark.asyncio + async def test_execute_run_delegates(self): + """execute_run delegates to TranslationStageRunner.""" + orch, _, _, _, MockRunner, _ = self._make_orchestrator() + mock_run = MagicMock() + orch._runner = MockRunner.return_value + orch._runner.execute_run = AsyncMock(return_value=mock_run) + + progress = MagicMock() + result = await orch.execute_run(mock_run, on_batch_progress=progress, skip_insert=True) + + orch._runner.execute_run.assert_called_once_with( + run=mock_run, on_batch_progress=progress, skip_insert=True, + ) + assert result == mock_run + + @pytest.mark.asyncio + async def test_retry_failed_batches_delegates(self): + """retry_failed_batches delegates to TranslationStageRunner.""" + orch, _, _, _, MockRunner, _ = self._make_orchestrator() + mock_run = MagicMock() + orch._runner = MockRunner.return_value + orch._runner.retry_failed_batches = AsyncMock(return_value=mock_run) + + result = await orch.retry_failed_batches("run-1") + + orch._runner.retry_failed_batches.assert_called_once_with("run-1") + + @pytest.mark.asyncio + async def test_retry_insert_delegates(self): + """retry_insert delegates to TranslationStageRunner.""" + orch, _, _, _, MockRunner, _ = self._make_orchestrator() + mock_run = MagicMock() + orch._runner = MockRunner.return_value + orch._runner.retry_insert = AsyncMock(return_value=mock_run) + + result = await orch.retry_insert("run-1") + + orch._runner.retry_insert.assert_called_once_with("run-1") + + def test_cancel_run_delegates(self): + """cancel_run delegates to TranslationStageRunner.""" + orch, _, _, _, MockRunner, _ = self._make_orchestrator() + mock_run = MagicMock() + orch._runner = MockRunner.return_value + orch._runner.cancel_run.return_value = mock_run + + result = orch.cancel_run("run-1") + + orch._runner.cancel_run.assert_called_once_with("run-1") + + def test_get_run_status_delegates(self): + """get_run_status delegates to TranslationResultAggregator.""" + orch, _, _, _, _, MockAggregator = self._make_orchestrator() + orch._aggregator = MockAggregator.return_value + orch._aggregator.get_run_status.return_value = {"status": "COMPLETED"} + + result = orch.get_run_status("run-1") + + orch._aggregator.get_run_status.assert_called_once_with("run-1") + + def test_get_run_records_delegates(self): + """get_run_records delegates to TranslationResultAggregator.""" + orch, _, _, _, _, MockAggregator = self._make_orchestrator() + orch._aggregator = MockAggregator.return_value + orch._aggregator.get_run_records.return_value = {"items": [], "total": 0} + + result = orch.get_run_records("run-1", page=2, page_size=10, status_filter="failed", + deduplicate=True) + + orch._aggregator.get_run_records.assert_called_once_with( + "run-1", 2, 10, "failed", deduplicate=True, + ) + + def test_get_run_history_delegates(self): + """get_run_history delegates to TranslationResultAggregator.""" + orch, _, _, _, _, MockAggregator = self._make_orchestrator() + orch._aggregator = MockAggregator.return_value + orch._aggregator.get_run_history.return_value = (5, [{"id": "r1"}]) + + total, items = orch.get_run_history("job-1", page=1, page_size=20) + + orch._aggregator.get_run_history.assert_called_once_with("job-1", 1, 20) + assert total == 5 + assert items == [{"id": "r1"}] + + def test_generate_and_insert_sql_backward_compat(self): + """_generate_and_insert_sql creates SQLInsertService and delegates.""" + orch, db, config, _, _, _ = self._make_orchestrator() + mock_job = MagicMock() + mock_run = MagicMock() + + # SQLInsertService is imported INSIDE the method from .orchestrator_sql + with patch( + "src.plugins.translate.orchestrator_sql.SQLInsertService" + ) as MockSQL: + mock_svc = MockSQL.return_value + mock_svc.generate_and_insert_sql.return_value = {"status": "success"} + + import asyncio + result = asyncio.run(orch._generate_and_insert_sql(mock_job, mock_run)) + + MockSQL.assert_called_once_with(db, config, orch.event_log) + mock_svc.generate_and_insert_sql.assert_called_once_with(mock_job, mock_run) + + def test_update_language_stats_delegates(self): + """_update_language_stats delegates to TranslationResultAggregator.""" + orch, _, _, _, _, MockAggregator = self._make_orchestrator() + orch._aggregator = MockAggregator.return_value + stats_map = {"ru": MagicMock()} + + orch._update_language_stats("run-1", stats_map) + + orch._aggregator.update_language_stats.assert_called_once_with("run-1", stats_map) + + def test_initializes_event_log_and_components(self): + """Constructor initializes all sub-components.""" + db = MagicMock() + config = MagicMock() + + with patch( + "src.plugins.translate.orchestrator.TranslationPlanner" + ) as MockPlanner, patch( + "src.plugins.translate.orchestrator.TranslationStageRunner" + ) as MockRunner, patch( + "src.plugins.translate.orchestrator.TranslationResultAggregator" + ) as MockAggregator: + orch = TranslationOrchestrator(db, config, "user") + + assert orch.db == db + assert orch.config_manager == config + assert orch.current_user == "user" + assert orch.event_log is not None + MockPlanner.assert_called_once_with(db, orch.event_log, "user") + MockRunner.assert_called_once_with(db, config, orch.event_log, "user") + MockAggregator.assert_called_once_with(db, orch.event_log) +# #endregion Test.Orchestrator diff --git a/backend/tests/plugins/translate/test_orchestrator_aggregator.py b/backend/tests/plugins/translate/test_orchestrator_aggregator.py new file mode 100644 index 00000000..c447e74d --- /dev/null +++ b/backend/tests/plugins/translate/test_orchestrator_aggregator.py @@ -0,0 +1,220 @@ +# #region Test.OrchestratorAggregator [C:3] [TYPE Module] [SEMANTICS test, translate, aggregator, records, history] +# @BRIEF Tests for orchestrator_aggregator.py — TranslationResultAggregator. +# @RELATION BINDS_TO -> [TranslationResultAggregator] +# @TEST_CONTRACT: get_run_status -> dict | run status with statistics +# @TEST_CONTRACT: get_run_records -> dict | delegated to orchestrator_query +# @TEST_CONTRACT: get_run_history -> tuple[int, list] | delegated to orchestrator_query +# @TEST_CONTRACT: update_language_stats -> None | delegated to orchestrator_lang_stats +# @TEST_EDGE: missing_run +# @TEST_EDGE: empty_language_stats + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch + +import pytest + +from src.plugins.translate.orchestrator_aggregator import TranslationResultAggregator + + +class TestGetRunStatus: + """TranslationResultAggregator.get_run_status — run status with stats.""" + + def _make_run(self, **kwargs): + run = MagicMock() + run.id = "run-1" + run.job_id = "job-1" + run.status = "COMPLETED" + run.trigger_type = "manual" + run.started_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + run.completed_at = datetime(2026, 1, 15, 13, 0, 0, tzinfo=timezone.utc) + run.error_message = None + run.total_records = 100 + run.successful_records = 95 + run.failed_records = 3 + run.skipped_records = 2 + run.cache_hits = 5 + run.insert_status = "success" + run.insert_method = "cte_insert" + run.connection_snapshot = {"db": "test"} + run.config_snapshot = {"batch_size": 50} + run.superset_execution_id = "q-1" + run.created_by = "user" + run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + for k, v in kwargs.items(): + setattr(run, k, v) + return run + + def _make_lang_stat(self, code: str, **kwargs): + ls = MagicMock() + ls.language_code = code + ls.total_rows = 50 + ls.translated_rows = 48 + ls.failed_rows = 1 + ls.skipped_rows = 1 + ls.token_count = 1000 + ls.estimated_cost = 0.002 + for k, v in kwargs.items(): + setattr(ls, k, v) + return ls + + def test_valid_run_returns_full_status(self): + """Valid run returns dict with all fields.""" + db = MagicMock() + event_log = MagicMock() + run = self._make_run() + db.query.return_value.filter.return_value.first.return_value = run + + db.query.return_value.filter.return_value.count.return_value = 5 # batch_count + + event_log.get_run_event_summary.return_value = { + "has_run_started": True, + "terminal_event_count": 1, + "invariant_valid": True, + } + + lang_stats = [self._make_lang_stat("ru"), self._make_lang_stat("de")] + db.query.return_value.filter.return_value.all.return_value = lang_stats + + aggregator = TranslationResultAggregator(db, event_log) + result = aggregator.get_run_status("run-1") + + assert result["id"] == "run-1" + assert result["status"] == "COMPLETED" + assert result["total_records"] == 100 + assert result["batch_count"] == 5 + assert len(result["language_stats"]) == 2 + assert result["language_stats"][0]["language_code"] == "ru" + assert result["language_stats"][0]["total_rows"] == 50 + assert result["event_invariants"]["invariant_valid"] is True + + def test_missing_run_raises(self): + """Missing run raises ValueError.""" + db = MagicMock() + event_log = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + aggregator = TranslationResultAggregator(db, event_log) + with pytest.raises(ValueError, match="Run 'bad-id' not found"): + aggregator.get_run_status("bad-id") + + def test_no_language_stats_returns_empty(self): + """No language stats returns empty list.""" + db = MagicMock() + event_log = MagicMock() + run = self._make_run() + db.query.return_value.filter.return_value.first.return_value = run + db.query.return_value.filter.return_value.count.return_value = 0 + event_log.get_run_event_summary.return_value = { + "has_run_started": True, "terminal_event_count": 0, "invariant_valid": True, + } + db.query.return_value.filter.return_value.all.return_value = [] + + aggregator = TranslationResultAggregator(db, event_log) + result = aggregator.get_run_status("run-1") + + assert result["language_stats"] == [] + + def test_none_dates_returns_none(self): + """None dates produce None isoformat.""" + db = MagicMock() + event_log = MagicMock() + run = self._make_run(started_at=None, completed_at=None) + db.query.return_value.filter.return_value.first.return_value = run + db.query.return_value.filter.return_value.count.return_value = 0 + event_log.get_run_event_summary.return_value = { + "has_run_started": True, "terminal_event_count": 0, "invariant_valid": True, + } + db.query.return_value.filter.return_value.all.return_value = [] + + aggregator = TranslationResultAggregator(db, event_log) + result = aggregator.get_run_status("run-1") + + assert result["started_at"] is None + assert result["completed_at"] is None + + def test_none_numeric_fields_defaults_zero(self): + """Numeric fields default to 0 when None.""" + db = MagicMock() + event_log = MagicMock() + run = self._make_run( + total_records=None, successful_records=None, + failed_records=None, skipped_records=None, cache_hits=None, + ) + db.query.return_value.filter.return_value.first.return_value = run + db.query.return_value.filter.return_value.count.return_value = 0 + event_log.get_run_event_summary.return_value = { + "has_run_started": True, "terminal_event_count": 0, "invariant_valid": True, + } + db.query.return_value.filter.return_value.all.return_value = [] + + aggregator = TranslationResultAggregator(db, event_log) + result = aggregator.get_run_status("run-1") + + assert result["total_records"] == 0 + assert result["successful_records"] == 0 + assert result["failed_records"] == 0 + assert result["skipped_records"] == 0 + assert result["cache_hits"] == 0 + + +class TestGetRunRecords: + """TranslationResultAggregator.get_run_records — delegates to orchestrator_query.""" + + def test_delegates_to_query_function(self): + """Calls get_run_records from orchestrator_query.""" + db = MagicMock() + event_log = MagicMock() + + with patch("src.plugins.translate.orchestrator_aggregator._get_run_records") as mock_fn: + mock_fn.return_value = {"items": [], "total": 0} + aggregator = TranslationResultAggregator(db, event_log) + result = aggregator.get_run_records("run-1", 2, 25, "failed", True) + + mock_fn.assert_called_once_with(db, "run-1", 2, 25, "failed", deduplicate=True) + assert result == {"items": [], "total": 0} + + +class TestGetRunHistory: + """TranslationResultAggregator.get_run_history — delegates to orchestrator_query.""" + + def test_delegates_to_query_function(self): + """Calls get_run_history from orchestrator_query.""" + db = MagicMock() + event_log = MagicMock() + + with patch("src.plugins.translate.orchestrator_aggregator._get_run_history") as mock_fn: + mock_fn.return_value = (5, [{"id": "r1"}]) + aggregator = TranslationResultAggregator(db, event_log) + total, items = aggregator.get_run_history("job-1", 1, 20) + + mock_fn.assert_called_once_with(db, "job-1", 1, 20) + assert total == 5 + assert items == [{"id": "r1"}] + + +class TestUpdateLanguageStats: + """TranslationResultAggregator.update_language_stats — delegates to orchestrator_lang_stats.""" + + def test_delegates_to_update_function(self): + """Calls update_language_stats from orchestrator_lang_stats.""" + db = MagicMock() + event_log = MagicMock() + stats_map = {"ru": MagicMock()} + + with patch("src.plugins.translate.orchestrator_aggregator.update_language_stats") as mock_fn: + aggregator = TranslationResultAggregator(db, event_log) + aggregator.update_language_stats("run-1", stats_map) + + mock_fn.assert_called_once_with( + db=db, event_log=event_log, run_id="run-1", language_stats_map=stats_map, + ) +# #endregion Test.OrchestratorAggregator diff --git a/backend/tests/plugins/translate/test_orchestrator_exec.py b/backend/tests/plugins/translate/test_orchestrator_exec.py new file mode 100644 index 00000000..d6135e32 --- /dev/null +++ b/backend/tests/plugins/translate/test_orchestrator_exec.py @@ -0,0 +1,214 @@ +# #region Test.OrchestratorExec [C:3] [TYPE Module] [SEMANTICS test, translate, execution, run, engine] +# @BRIEF Tests for orchestrator_exec.py — TranslationExecutionEngine. +# @RELATION BINDS_TO -> [TranslationExecutionEngine] +# @TEST_CONTRACT: execute_run -> TranslationRun | dispatches executor, handles outcomes +# @TEST_CONTRACT: _init_language_stats -> dict | initializes per-language stats +# @TEST_EDGE: missing_job +# @TEST_EDGE: wrong_status +# @TEST_EDGE: executor_failure +# @TEST_EDGE: cancelled_run + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from src.plugins.translate.orchestrator_exec import TranslationExecutionEngine + + +class TestTranslationExecutionEngine: + """TranslationExecutionEngine — execute runs, handle outcomes.""" + + def _make_engine(self): + """Create engine with mocked deps.""" + db = MagicMock() + config = MagicMock() + event_log = MagicMock() + engine = TranslationExecutionEngine(db, config, event_log, "user") + return engine, db, config, event_log + + def _make_run(self, **kwargs): + run = MagicMock() + run.id = "run-1" + run.job_id = "job-1" + run.status = "PENDING" + for k, v in kwargs.items(): + setattr(run, k, v) + return run + + def _make_job(self, **kwargs): + job = MagicMock() + job.id = "job-1" + job.target_languages = ["ru", "de"] + job.target_dialect = "en" + for k, v in kwargs.items(): + setattr(job, k, v) + return job + + @pytest.mark.asyncio + async def test_execute_run_success(self): + """Happy path: run executed, success completion handled.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + + with patch( + "src.plugins.translate.orchestrator_exec.TranslationExecutor" + ) as MockExecutor: + mock_executor = MockExecutor.return_value + mock_executor.execute_run = AsyncMock(return_value=run) + + with patch( + "src.plugins.translate.orchestrator_exec._complete_success" + ) as mock_success: + mock_success.return_value = run + result = await engine.execute_run(run) + + event_log.log_event.assert_called_once_with( + job_id="job-1", run_id="run-1", event_type="TRANSLATION_PHASE_STARTED", + payload={}, created_by="user", + ) + mock_success.assert_called_once() + + @pytest.mark.asyncio + async def test_execute_run_missing_job_raises(self): + """Missing job raises ValueError.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run() + db.query.return_value.filter.return_value.first.return_value = None + + with pytest.raises(ValueError, match="Job 'job-1' not found"): + await engine.execute_run(run) + + @pytest.mark.asyncio + async def test_execute_run_wrong_status_raises(self): + """Non-PENDING run raises ValueError.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run(status="RUNNING") + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + + with pytest.raises(ValueError, match="Cannot execute run in status 'RUNNING'"): + await engine.execute_run(run) + + @pytest.mark.asyncio + async def test_executor_failure_handled(self): + """Executor exception triggers failure handler.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + + with patch( + "src.plugins.translate.orchestrator_exec.TranslationExecutor" + ) as MockExecutor: + mock_executor = MockExecutor.return_value + mock_executor.execute_run = AsyncMock(side_effect=ValueError("LLM failed")) + + with patch( + "src.plugins.translate.orchestrator_exec._handle_executor_failure" + ) as mock_failure: + mock_failure.return_value = run + result = await engine.execute_run(run) + + mock_failure.assert_called_once() + assert result == run + + @pytest.mark.asyncio + async def test_cancelled_run_handled(self): + """Run with CANCELLED status after executor triggers complete_cancelled.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + + with patch( + "src.plugins.translate.orchestrator_exec.TranslationExecutor" + ) as MockExecutor: + cancelled_run = self._make_run(status="CANCELLED") + mock_executor = MockExecutor.return_value + mock_executor.execute_run = AsyncMock(return_value=cancelled_run) + + with patch( + "src.plugins.translate.orchestrator_exec._complete_cancelled" + ) as mock_cancelled: + mock_cancelled.return_value = cancelled_run + result = await engine.execute_run(run) + + mock_cancelled.assert_called_once() + assert result == cancelled_run + + @pytest.mark.asyncio + async def test_executor_passed_progress_callback(self): + """Progress callback and skip_insert passed to executor.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + + progress_cb = MagicMock() + + with patch( + "src.plugins.translate.orchestrator_exec.TranslationExecutor" + ) as MockExecutor: + mock_executor = MockExecutor.return_value + mock_executor.execute_run = AsyncMock(return_value=run) + + with patch( + "src.plugins.translate.orchestrator_exec._complete_success" + ) as mock_success: + mock_success.return_value = run + result = await engine.execute_run(run, on_batch_progress=progress_cb, skip_insert=True) + + # Verify executor was created with callback + MockExecutor.assert_called_once() + _, kwargs = MockExecutor.call_args + assert kwargs["on_batch_progress"] == progress_cb + + def test_init_language_stats(self): + """_init_language_stats creates stats for each target language.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run() + job = self._make_job(target_languages=["ru", "de"]) + + result = engine._init_language_stats(run, job) + + assert len(result) == 2 + assert "ru" in result + assert "de" in result + assert result["ru"].language_code == "ru" + assert result["ru"].run_id == "run-1" + assert db.add.call_count == 2 + db.flush.assert_called_once() + + def test_init_language_stats_string_languages(self): + """String target_languages is converted to list.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run() + job = self._make_job(target_languages=None, target_dialect="fr") + + result = engine._init_language_stats(run, job) + + assert len(result) == 1 + assert "fr" in result + + def test_init_language_stats_default_fallback(self): + """No target_languages and no target_dialect defaults to 'en'.""" + engine, db, config, event_log = self._make_engine() + run = self._make_run() + job = self._make_job(target_languages=None, target_dialect=None) + + result = engine._init_language_stats(run, job) + + assert len(result) == 1 + assert "en" in result +# #endregion Test.OrchestratorExec diff --git a/backend/tests/plugins/translate/test_orchestrator_planner.py b/backend/tests/plugins/translate/test_orchestrator_planner.py new file mode 100644 index 00000000..f774de6c --- /dev/null +++ b/backend/tests/plugins/translate/test_orchestrator_planner.py @@ -0,0 +1,227 @@ +# #region Test.OrchestratorPlanner [C:3] [TYPE Module] [SEMANTICS test, translate, planner, run, creation] +# @BRIEF Tests for orchestrator_planner.py — TranslationPlanner.plan_run. +# @RELATION BINDS_TO -> [TranslationPlanner] +# @TEST_CONTRACT: plan_run -> TranslationRun | creates run with config snapshot and hashes +# @TEST_EDGE: missing_job +# @TEST_EDGE: scheduled_trigger +# @TEST_EDGE: full_translation + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from unittest.mock import MagicMock, patch + +import pytest + +from src.plugins.translate.orchestrator_planner import TranslationPlanner + + +class TestTranslationPlanner: + """TranslationPlanner.plan_run — create TranslationRun with hashes.""" + + def _make_job(self, **kwargs): + job = MagicMock() + job.id = "job-1" + job.source_dialect = "postgresql" + job.target_dialect = "clickhouse" + job.database_dialect = "postgresql" + job.source_datasource_id = "ds-1" + job.source_table = "source_table" + job.target_schema = "target_schema" + job.target_table = "target_table" + job.source_key_cols = ["id"] + job.target_key_cols = ["id"] + job.translation_column = "name" + job.target_column = "name_translated" + job.context_columns = ["desc"] + job.provider_id = "provider-1" + job.batch_size = 50 + job.upsert_strategy = "MERGE" + job.target_languages = ["ru", "de"] + for k, v in kwargs.items(): + setattr(job, k, v) + return job + + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_plan_run_success( + self, mock_validate, mock_dict_hash, mock_config_hash + ): + """Creates run with all required fields.""" + db = MagicMock() + event_log = MagicMock() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + mock_config_hash.return_value = "config_hash_1234" + mock_dict_hash.return_value = "dict_hash_5678" + + planner = TranslationPlanner(db, event_log, "user") + result = planner.plan_run("job-1") + + assert result.job_id == "job-1" + assert result.status == "PENDING" + assert result.trigger_type == "manual" + assert result.config_hash == "config_hash_1234" + assert result.dict_snapshot_hash == "dict_hash_5678" + assert result.key_hash is not None + assert result.created_by == "user" + assert result.config_snapshot is not None + assert result.config_snapshot["source_dialect"] == "postgresql" + assert result.config_snapshot["target_dialect"] == "clickhouse" + assert result.config_snapshot["batch_size"] == 50 + assert result.config_snapshot["full_translation"] is False + db.add.assert_called_once_with(result) + db.flush.assert_called() + db.commit.assert_called() + db.refresh.assert_called_once_with(result) + + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_missing_job_raises( + self, mock_validate, mock_dict_hash, mock_config_hash + ): + """Missing job raises ValueError.""" + db = MagicMock() + event_log = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + + planner = TranslationPlanner(db, event_log, "user") + with pytest.raises(ValueError, match="Translation job 'bad-id' not found"): + planner.plan_run("bad-id") + + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_scheduled_trigger( + self, mock_validate, mock_dict_hash, mock_config_hash + ): + """is_scheduled=True sets trigger_type to 'scheduled'.""" + db = MagicMock() + event_log = MagicMock() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + mock_config_hash.return_value = "hash1" + mock_dict_hash.return_value = "hash2" + + planner = TranslationPlanner(db, event_log, "user") + result = planner.plan_run("job-1", is_scheduled=True) + + assert result.trigger_type == "scheduled" + + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_custom_trigger_type( + self, mock_validate, mock_dict_hash, mock_config_hash + ): + """Custom trigger_type is preserved.""" + db = MagicMock() + event_log = MagicMock() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + mock_config_hash.return_value = "hash1" + mock_dict_hash.return_value = "hash2" + + planner = TranslationPlanner(db, event_log, "user") + result = planner.plan_run("job-1", trigger_type="api_webhook") + + assert result.trigger_type == "api_webhook" + + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_full_translation_flag( + self, mock_validate, mock_dict_hash, mock_config_hash + ): + """full_translation=True sets flag in config_snapshot.""" + db = MagicMock() + event_log = MagicMock() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + mock_config_hash.return_value = "hash1" + mock_dict_hash.return_value = "hash2" + + planner = TranslationPlanner(db, event_log, "user") + result = planner.plan_run("job-1", full_translation=True) + + assert result.config_snapshot["full_translation"] is True + + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_event_logged_on_creation( + self, mock_validate, mock_dict_hash, mock_config_hash + ): + """RUN_STARTED event logged after run creation.""" + db = MagicMock() + event_log = MagicMock() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + mock_config_hash.return_value = "ch" + mock_dict_hash.return_value = "dh" + + planner = TranslationPlanner(db, event_log, "user") + result = planner.plan_run("job-1") + + event_log.log_event.assert_called_once() + call_args = event_log.log_event.call_args[1] + assert call_args["event_type"] == "RUN_STARTED" + assert call_args["run_id"] == result.id + assert call_args["payload"]["config_hash"] == "ch" + + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_validates_preconditions( + self, mock_validate, mock_dict_hash, mock_config_hash + ): + """Calls validate_job_preconditions with correct args.""" + db = MagicMock() + event_log = MagicMock() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + mock_config_hash.return_value = "ch" + mock_dict_hash.return_value = "dh" + + planner = TranslationPlanner(db, event_log, "user") + planner.plan_run("job-1", is_scheduled=True) + + mock_validate.assert_called_once_with(job, db, is_scheduled=True) + + @patch("src.plugins.translate.orchestrator_planner.compute_config_hash") + @patch("src.plugins.translate.orchestrator_planner.compute_dict_snapshot_hash") + @patch("src.plugins.translate.orchestrator_planner.validate_job_preconditions") + def test_config_snapshot_includes_all_fields( + self, mock_validate, mock_dict_hash, mock_config_hash + ): + """Config snapshot contains all relevant job fields.""" + db = MagicMock() + event_log = MagicMock() + job = self._make_job() + db.query.return_value.filter.return_value.first.return_value = job + mock_config_hash.return_value = "ch" + mock_dict_hash.return_value = "dh" + + planner = TranslationPlanner(db, event_log, "user") + result = planner.plan_run("job-1") + + snapshot = result.config_snapshot + assert snapshot["source_dialect"] == "postgresql" + assert snapshot["target_dialect"] == "clickhouse" + assert snapshot["source_datasource_id"] == "ds-1" + assert snapshot["source_table"] == "source_table" + assert snapshot["target_schema"] == "target_schema" + assert snapshot["target_table"] == "target_table" + assert snapshot["source_key_cols"] == ["id"] + assert snapshot["translation_column"] == "name" + assert snapshot["provider_id"] == "provider-1" + assert snapshot["upsert_strategy"] == "MERGE" +# #endregion Test.OrchestratorPlanner diff --git a/backend/tests/plugins/translate/test_orchestrator_query.py b/backend/tests/plugins/translate/test_orchestrator_query.py new file mode 100644 index 00000000..c72a7775 --- /dev/null +++ b/backend/tests/plugins/translate/test_orchestrator_query.py @@ -0,0 +1,280 @@ +# #region Test.OrchestratorQuery [C:3] [TYPE Module] [SEMANTICS test, translate, query, records, history] +# @BRIEF Tests for orchestrator_query.py — get_run_records, get_run_history. +# @RELATION BINDS_TO -> [orchestrator_query] +# @TEST_CONTRACT: get_run_records -> dict | paginated records with optional deduplicate +# @TEST_CONTRACT: get_run_history -> tuple[int, list[dict]] | paginated run history +# @TEST_EDGE: deduplicate_empty_source_hash +# @TEST_EDGE: status_filter + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from datetime import datetime, timezone +from unittest.mock import MagicMock, patch, ANY + +import pytest + +from src.plugins.translate.orchestrator_query import get_run_records, get_run_history + + +class TestGetRunRecords: + """get_run_records — paginated run records.""" + + def _make_record(self, rec_id: str, **kwargs): + rec = MagicMock() + rec.id = rec_id + rec.batch_id = "batch-1" + rec.source_sql = "SELECT * FROM t" + rec.target_sql = "SELECT * FROM t_translated" + rec.source_object_type = "table" + rec.source_object_id = "123" + rec.source_object_name = "my_table" + rec.source_data = {"col": "val"} + rec.source_hash = f"hash_{rec_id}" + rec.status = "translated" + rec.error_message = None + rec.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + rec.languages = [] + for k, v in kwargs.items(): + setattr(rec, k, v) + return rec + + def test_basic_query(self): + """Returns paginated records with metadata.""" + db = MagicMock() + # Mock chain: db.query().options().filter() + options_mock = MagicMock() + filter_mock = MagicMock() + options_filter_mock = MagicMock() + + db.query.return_value.options.return_value = options_mock + options_mock.filter.return_value = options_filter_mock + options_filter_mock.count.return_value = 2 + options_filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [ + self._make_record("r1"), self._make_record("r2"), + ] + + result = get_run_records(db, "run-1") + + assert result["total"] == 2 + assert len(result["items"]) == 2 + assert result["page"] == 1 + assert result["page_size"] == 50 + assert result["status_filter"] is None + assert result["deduplicate"] is False + + def test_with_status_filter(self): + """Status filter applied to query.""" + db = MagicMock() + options_mock = MagicMock() + filter_mock1 = MagicMock() + filter_mock2 = MagicMock() + + db.query.return_value.options.return_value = options_mock + options_mock.filter.return_value = filter_mock1 + filter_mock1.filter.return_value = filter_mock2 + filter_mock2.count.return_value = 1 + filter_mock2.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [ + self._make_record("r1", status="failed"), + ] + + result = get_run_records(db, "run-1", status_filter="failed") + + assert result["total"] == 1 + assert result["status_filter"] == "failed" + + def test_deduplicate_applied(self): + """Deduplicate flag applies exclusion logic.""" + db = MagicMock() + options_mock = MagicMock() + filter_mock1 = MagicMock() + filter_mock2 = MagicMock() + + db.query.return_value.options.return_value = options_mock + options_mock.filter.return_value = filter_mock1 + filter_mock1.filter.return_value = filter_mock2 + filter_mock2.count.return_value = 1 + filter_mock2.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [ + self._make_record("r1", source_hash="abc123"), + ] + + result = get_run_records(db, "run-1", deduplicate=True) + + assert result["deduplicate"] is True + assert len(result["items"]) == 1 + + def test_empty_records(self): + """No records returns empty list.""" + db = MagicMock() + options_mock = MagicMock() + + db.query.return_value.options.return_value = options_mock + options_mock.filter.return_value.count.return_value = 0 + options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [] + + result = get_run_records(db, "run-1") + + assert result["total"] == 0 + assert result["items"] == [] + + def test_record_languages_included(self): + """Language entries included in record dict.""" + db = MagicMock() + lang = MagicMock() + lang.language_code = "ru" + lang.translated_value = "привет" + lang.final_value = None + lang.source_language_detected = "en" + lang.status = "translated" + lang.needs_review = False + + rec = self._make_record("r1", languages=[lang]) + options_mock = MagicMock() + db.query.return_value.options.return_value = options_mock + options_mock.filter.return_value.count.return_value = 1 + options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec] + + result = get_run_records(db, "run-1") + + assert len(result["items"][0]["languages"]) == 1 + assert result["items"][0]["languages"][0]["language_code"] == "ru" + assert result["items"][0]["languages"][0]["final_value"] == "привет" + + def test_record_without_created_at(self): + """Record without created_at returns None for isoformat.""" + db = MagicMock() + rec = self._make_record("r1", created_at=None) + options_mock = MagicMock() + db.query.return_value.options.return_value = options_mock + options_mock.filter.return_value.count.return_value = 1 + options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec] + + result = get_run_records(db, "run-1") + + assert result["items"][0]["created_at"] is None + + def test_pagination_params(self): + """Pagination params affect offset and limit.""" + db = MagicMock() + rec = self._make_record("r1") + options_mock = MagicMock() + db.query.return_value.options.return_value = options_mock + options_mock.filter.return_value.count.return_value = 10 + options_mock.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [rec] + + result = get_run_records(db, "run-1", page=2, page_size=10) + + assert result["page"] == 2 + assert result["page_size"] == 10 + + +class TestGetRunHistory: + """get_run_history — paginated run history for a job.""" + + def _make_run(self, run_id: str, **kwargs): + run = MagicMock() + run.id = run_id + run.job_id = "job-1" + run.status = "COMPLETED" + run.trigger_type = "manual" + run.started_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + run.completed_at = datetime(2026, 1, 15, 13, 0, 0, tzinfo=timezone.utc) + run.error_message = None + run.total_records = 100 + run.successful_records = 95 + run.failed_records = 3 + run.skipped_records = 2 + run.cache_hits = 5 + run.insert_status = "success" + run.insert_method = "cte_insert" + run.connection_snapshot = {"db": "test"} + run.superset_execution_id = "q-1" + run.created_by = "user" + run.created_at = datetime(2026, 1, 15, 12, 0, 0, tzinfo=timezone.utc) + for k, v in kwargs.items(): + setattr(run, k, v) + return run + + def test_basic_history(self): + """Returns total count and run list.""" + db = MagicMock() + runs = [self._make_run("r1"), self._make_run("r2")] + filter_mock = MagicMock() + db.query.return_value.filter.return_value = filter_mock + filter_mock.count.return_value = 2 + filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = runs + + total, items = get_run_history(db, "job-1") + + assert total == 2 + assert len(items) == 2 + assert items[0]["id"] == "r1" + assert items[0]["status"] == "COMPLETED" + + def test_empty_history(self): + """No runs returns total=0 and empty list.""" + db = MagicMock() + filter_mock = MagicMock() + db.query.return_value.filter.return_value = filter_mock + filter_mock.count.return_value = 0 + filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [] + + total, items = get_run_history(db, "job-1") + + assert total == 0 + assert items == [] + + def test_pagination_params(self): + """Pagination affects offset and limit.""" + db = MagicMock() + runs = [self._make_run("r1")] + filter_mock = MagicMock() + db.query.return_value.filter.return_value = filter_mock + filter_mock.count.return_value = 10 + filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = runs + + total, items = get_run_history(db, "job-1", page=3, page_size=5) + + assert total == 10 + assert len(items) == 1 + + def test_run_without_dates(self): + """Run without started_at/completed_at returns None.""" + db = MagicMock() + run = self._make_run("r1", started_at=None, completed_at=None) + filter_mock = MagicMock() + db.query.return_value.filter.return_value = filter_mock + filter_mock.count.return_value = 1 + filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [run] + + total, items = get_run_history(db, "job-1") + + assert items[0]["started_at"] is None + assert items[0]["completed_at"] is None + + def test_all_numeric_fields_default_to_zero(self): + """Numeric fields default to 0 when None.""" + db = MagicMock() + run = self._make_run( + "r1", total_records=None, successful_records=None, + failed_records=None, skipped_records=None, cache_hits=None, + ) + filter_mock = MagicMock() + db.query.return_value.filter.return_value = filter_mock + filter_mock.count.return_value = 1 + filter_mock.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [run] + + total, items = get_run_history(db, "job-1") + + assert items[0]["total_records"] == 0 + assert items[0]["successful_records"] == 0 + assert items[0]["failed_records"] == 0 + assert items[0]["skipped_records"] == 0 + assert items[0]["cache_hits"] == 0 +# #endregion Test.OrchestratorQuery diff --git a/backend/tests/plugins/translate/test_orchestrator_retry.py b/backend/tests/plugins/translate/test_orchestrator_retry.py new file mode 100644 index 00000000..044df405 --- /dev/null +++ b/backend/tests/plugins/translate/test_orchestrator_retry.py @@ -0,0 +1,253 @@ +# #region Test.OrchestratorRetry [C:3] [TYPE Module] [SEMANTICS test, translate, retry, cancellation] +# @BRIEF Tests for orchestrator_retry.py — TranslationRunRetryManager. +# @RELATION BINDS_TO -> [TranslationRunRetryManager] +# @TEST_CONTRACT: retry_failed_batches -> TranslationRun | retries failed batches +# @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to orchestrator_cancel.retry_insert +# @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to orchestrator_cancel.cancel_run +# @TEST_EDGE: no_failed_batches +# @TEST_EDGE: empty_failed_records +# @TEST_EDGE: all_retry_successful + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from src.plugins.translate.orchestrator_retry import TranslationRunRetryManager + + +class TestTranslationRunRetryManager: + """TranslationRunRetryManager — retry and cancellation.""" + + def _make_retry_mgr(self): + """Create retry manager with mock deps.""" + db = MagicMock() + config = MagicMock() + event_log = MagicMock() + mgr = TranslationRunRetryManager(db, config, event_log, "user") + return mgr, db, config, event_log + + def _make_run(self, **kwargs): + run = MagicMock() + run.id = "run-1" + run.job_id = "job-1" + run.status = "COMPLETED" + run.successful_records = 90 + run.failed_records = 5 + run.skipped_records = 5 + run.completed_at = None + for k, v in kwargs.items(): + setattr(run, k, v) + return run + + def _make_batch(self, batch_index: int, status: str = "FAILED"): + batch = MagicMock() + batch.id = f"batch-{batch_index}" + batch.batch_index = batch_index + batch.status = status + return batch + + def _make_record(self, record_id: str, **kwargs): + rec = MagicMock() + rec.id = record_id + rec.source_object_id = "row_1" + rec.source_sql = "SELECT *" + rec.source_object_name = "table" + rec.status = "FAILED" + for k, v in kwargs.items(): + setattr(rec, k, v) + return rec + + @pytest.mark.asyncio + async def test_retry_failed_batches_success(self): + """Retry failed batches updates run stats.""" + mgr, db, config, event_log = self._make_retry_mgr() + mgr.event_log = event_log + run = self._make_run() + job = MagicMock() + job.id = "job-1" + + db.query.return_value.filter.return_value.first.side_effect = [run, job] + + batch1 = self._make_batch(1) + failed_rec = self._make_record("rec-1") + # Use side_effect on .all() to return different values per call + db.query.return_value.filter.return_value.all.side_effect = [ + [batch1], # 1st .all(): failed batches + [failed_rec], # 2nd .all(): failed records + ] + + with patch( + "src.plugins.translate.orchestrator_retry.TranslationExecutor" + ) as MockExecutor: + mock_executor = MockExecutor.return_value + mock_executor._process_batch = AsyncMock(return_value={ + "successful": 3, "failed": 1, "skipped": 0, + }) + + result = await mgr.retry_failed_batches("run-1") + + # failed=5+1=6, successful=90+3=93 (both non-zero -> COMPLETED) + assert result.status == "COMPLETED" + assert result.successful_records >= 90 + assert result.failed_records >= 5 + assert result.completed_at is not None + event_log.log_event.assert_any_call( + job_id="job-1", run_id="run-1", event_type="RUN_RETRYING", payload={"batch_count": 1}, + created_by="user", + ) + event_log.log_event.assert_any_call( + job_id="job-1", run_id="run-1", event_type="RUN_COMPLETED", + payload={"retry": True}, created_by="user", + ) + + @pytest.mark.asyncio + async def test_retry_missing_run_raises(self): + """Missing run raises ValueError.""" + mgr, db, config, event_log = self._make_retry_mgr() + db.query.return_value.filter.return_value.first.return_value = None + + with pytest.raises(ValueError, match="Run 'bad-id' not found"): + await mgr.retry_failed_batches("bad-id") + + @pytest.mark.asyncio + async def test_retry_missing_job_raises(self): + """Missing job raises ValueError.""" + mgr, db, config, event_log = self._make_retry_mgr() + run = self._make_run() + db.query.return_value.filter.return_value.first.side_effect = [run, None] + + with pytest.raises(ValueError, match="Job 'job-1' not found"): + await mgr.retry_failed_batches("run-1") + + @pytest.mark.asyncio + async def test_no_failed_batches_raises(self): + """No failed batches raises ValueError.""" + mgr, db, config, event_log = self._make_retry_mgr() + run = self._make_run() + job = MagicMock() + job.id = "job-1" + db.query.return_value.filter.return_value.first.side_effect = [run, job] + db.query.return_value.filter.return_value.all.return_value = [] + + with pytest.raises(ValueError, match="No failed batches found for run 'run-1'"): + await mgr.retry_failed_batches("run-1") + + @pytest.mark.asyncio + async def test_retry_skips_batches_with_no_failed_records(self): + """Batch with no failed records is skipped.""" + mgr, db, config, event_log = self._make_retry_mgr() + run = self._make_run() + job = MagicMock() + job.id = "job-1" + db.query.return_value.filter.return_value.first.side_effect = [run, job] + + batch1 = self._make_batch(1) + # Use .all.side_effect — don't overwrite .filter.return_value + db.query.return_value.filter.return_value.all.side_effect = [ + [batch1], # first .all(): failed batches + [], # second .all(): failed records + ] + + with patch( + "src.plugins.translate.orchestrator_retry.TranslationExecutor" + ) as MockExecutor: + mock_executor = MockExecutor.return_value + mock_executor._process_batch = AsyncMock() + + result = await mgr.retry_failed_batches("run-1") + + # Since no records, _process_batch should NOT be called + mock_executor._process_batch.assert_not_called() + assert result.completed_at is not None + + @pytest.mark.asyncio + async def test_retry_completed_without_errors(self): + """All retries successful -> status COMPLETED.""" + mgr, db, config, event_log = self._make_retry_mgr() + run = self._make_run(successful_records=90, failed_records=0, skipped_records=0) + job = MagicMock() + job.id = "job-1" + db.query.return_value.filter.return_value.first.side_effect = [run, job] + + batch1 = self._make_batch(1) + failed_rec = self._make_record("rec-1") + db.query.return_value.filter.return_value.all.side_effect = [ + [batch1], # failed batches + [failed_rec], # failed records + ] + + with patch( + "src.plugins.translate.orchestrator_retry.TranslationExecutor" + ) as MockExecutor: + mock_executor = MockExecutor.return_value + mock_executor._process_batch = AsyncMock(return_value={ + "successful": 5, "failed": 0, "skipped": 0, + }) + result = await mgr.retry_failed_batches("run-1") + + # failed=0+0=0 -> COMPLETED + assert result.status == "COMPLETED" + + @pytest.mark.asyncio + async def test_retry_all_failed(self): + """All retries fail -> status FAILED.""" + mgr, db, config, event_log = self._make_retry_mgr() + run = self._make_run(successful_records=0, failed_records=10, skipped_records=0) + job = MagicMock() + job.id = "job-1" + db.query.return_value.filter.return_value.first.side_effect = [run, job] + + batch1 = self._make_batch(1) + failed_rec = self._make_record("rec-1") + # Use .all.side_effect — don't overwrite .filter.return_value + db.query.return_value.filter.return_value.all.side_effect = [ + [batch1], # failed batches + [failed_rec], # failed records + ] + + with patch( + "src.plugins.translate.orchestrator_retry.TranslationExecutor" + ) as MockExecutor: + mock_executor = MockExecutor.return_value + mock_executor._process_batch = AsyncMock(return_value={ + "successful": 0, "failed": 5, "skipped": 0, + }) + result = await mgr.retry_failed_batches("run-1") + + # failed=10+5=15 != 0, successful=0+0=0 == 0 -> FAILED + assert result.status == "FAILED" + + @pytest.mark.asyncio + async def test_retry_insert_delegates(self): + """retry_insert delegates to orchestrator_cancel.retry_insert.""" + mgr, db, config, event_log = self._make_retry_mgr() + + with patch( + "src.plugins.translate.orchestrator_retry._retry_insert" + ) as mock_retry_insert: + mock_retry_insert.return_value = MagicMock(id="run-1") + result = await mgr.retry_insert("run-1") + + mock_retry_insert.assert_called_once_with(db, config, event_log, "user", "run-1") + + def test_cancel_run_delegates(self): + """cancel_run delegates to orchestrator_cancel.cancel_run.""" + mgr, db, config, event_log = self._make_retry_mgr() + + with patch( + "src.plugins.translate.orchestrator_retry._cancel_run" + ) as mock_cancel: + mock_cancel.return_value = MagicMock(id="run-1") + result = mgr.cancel_run("run-1") + + mock_cancel.assert_called_once_with(db, event_log, "user", "run-1") +# #endregion Test.OrchestratorRetry diff --git a/backend/tests/plugins/translate/test_orchestrator_run_completion.py b/backend/tests/plugins/translate/test_orchestrator_run_completion.py new file mode 100644 index 00000000..5dc31823 --- /dev/null +++ b/backend/tests/plugins/translate/test_orchestrator_run_completion.py @@ -0,0 +1,378 @@ +# #region Test.OrchestratorRunCompletion [C:3] [TYPE Module] [SEMANTICS test, translate, run, completion, failure] +# @BRIEF Tests for orchestrator_run_completion.py — handle_executor_failure, complete_cancelled, complete_success. +# @RELATION BINDS_TO -> [orchestrator_run_completion] +# @TEST_CONTRACT: handle_executor_failure -> TranslationRun | marks run as FAILED +# @TEST_CONTRACT: complete_cancelled -> TranslationRun | finalizes cancelled run +# @TEST_CONTRACT: complete_success -> TranslationRun | finalizes successful run +# @TEST_EDGE: rollback_fails +# @TEST_EDGE: skip_insert +# @TEST_EDGE: insert_failed + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from src.plugins.translate.orchestrator_run_completion import ( + handle_executor_failure, + complete_cancelled, + complete_success, +) + + +class TestHandleExecutorFailure: + """handle_executor_failure — mark run as FAILED with error info.""" + + def _make_run(self, **kwargs): + run = MagicMock() + run.id = "run-1" + run.status = "RUNNING" + for k, v in kwargs.items(): + setattr(run, k, v) + return run + + def _make_job(self): + job = MagicMock() + job.id = "job-1" + return job + + def test_marks_run_failed(self): + """Run status set to FAILED.""" + db = MagicMock() + db.merge.return_value = db.merge.return_value # default: returns new mock + event_log = MagicMock() + run = self._make_run() + job = self._make_job() + error = ValueError("LLM timeout") + + # Make db.merge return the original run so .id is consistent + db.merge.return_value = run + result = handle_executor_failure(db, event_log, run, job, error, "test-user") + + assert result.status == "FAILED" + assert "LLM timeout" in result.error_message + assert result.completed_at is not None + db.rollback.assert_called_once() + db.flush.assert_called_once() + db.commit.assert_called_once() + + def test_logs_event(self): + """RUN_FAILED event logged.""" + db = MagicMock() + db.merge.return_value = self._make_run(id="run-1") + event_log = MagicMock() + run = self._make_run() + job = self._make_job() + error = RuntimeError("fail") + + handle_executor_failure(db, event_log, run, job, error, "test-user") + + event_log.log_event.assert_called_once() + call_kwargs = event_log.log_event.call_args[1] + assert call_kwargs["job_id"] == "job-1" + assert call_kwargs["run_id"] == "run-1" + assert call_kwargs["event_type"] == "RUN_FAILED" + assert call_kwargs["payload"] == {"error": "fail", "phase": "translation"} + + def test_rollback_error_handled_gracefully(self): + """If db.rollback() raises, it's silently caught.""" + db = MagicMock() + db.rollback.side_effect = Exception("rollback error") + db.merge.return_value = self._make_run() + event_log = MagicMock() + run = self._make_run() + job = self._make_job() + error = Exception("exec error") + + result = handle_executor_failure(db, event_log, run, job, error, "test-user") + assert result.status == "FAILED" + db.rollback.assert_called_once() + assert db.merge.called # still continues + + def test_run_merged_after_rollback(self): + """Run is merged after rollback attempt.""" + db = MagicMock() + event_log = MagicMock() + run = self._make_run() + job = self._make_job() + error = Exception() + + handle_executor_failure(db, event_log, run, job, error) + db.merge.assert_called_once_with(run) + + def test_error_message_includes_phase(self): + """Error message includes 'Translation execution failed:' prefix.""" + db = MagicMock() + db.merge.return_value = self._make_run() + event_log = MagicMock() + run = self._make_run() + job = self._make_job() + + result = handle_executor_failure(db, event_log, run, job, ValueError("bad"), "user") + assert result.error_message.startswith("Translation execution failed:") + + +class TestCompleteCancelled: + """complete_cancelled — finalize cancelled run.""" + + def test_updates_stats_and_logs(self): + """Aggregator stats updated and event logged.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + run = MagicMock() + run.id = "run-1" + run.job_id = "job-1" + stats_map = {"ru": MagicMock()} + + result = complete_cancelled(db, event_log, aggregator, run, stats_map, "test-user") + + aggregator.update_language_stats.assert_called_once_with("run-1", stats_map) + event_log.log_event.assert_called_once_with( + job_id="job-1", run_id="run-1", event_type="RUN_CANCELLED", + payload={"reason": "cancellation_flag"}, created_by="test-user", + ) + db.commit.assert_called_once() + assert result == run + + def test_no_current_user(self): + """Works without current_user.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + run = MagicMock() + run.id = "run-1" + run.job_id = "job-1" + + result = complete_cancelled(db, event_log, aggregator, run, {}, None) + event_log.log_event.assert_called_once() + assert result == run + + +class TestCompleteSuccess: + """complete_success — finalize successful run.""" + + def _make_run(self, **kwargs): + run = MagicMock() + run.id = "run-1" + run.job_id = "job-1" + run.status = "RUNNING" + run.total_records = 100 + run.successful_records = 95 + run.failed_records = 3 + run.skipped_records = 2 + for k, v in kwargs.items(): + setattr(run, k, v) + return run + + def _make_job(self): + job = MagicMock() + job.id = "job-1" + return job + + @pytest.mark.asyncio + async def test_skip_insert_completes_run(self): + """skip_insert=True -> COMPLETED without SQL.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + sql_service = AsyncMock() + run = self._make_run() + job = self._make_job() + + result = await complete_success( + db, event_log, aggregator, sql_service, + run, job, skip_insert=True, + language_stats_map={}, current_user="u", + ) + + assert result.status == "COMPLETED" + assert result.completed_at is not None + aggregator.update_language_stats.assert_called_once() + sql_service.generate_and_insert_sql.assert_not_called() + assert db.commit.call_count >= 1 + + @pytest.mark.asyncio + async def test_insert_success(self): + """SQL insert succeeds -> COMPLETED.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + sql_service = AsyncMock() + sql_service.generate_and_insert_sql.return_value = { + "status": "success", "query_id": "q-1", + } + run = self._make_run() + job = self._make_job() + # After commit, run is re-queried + final_run = MagicMock(status="COMPLETED") + final_run.insert_status = "success" + final_run.superset_execution_id = "q-1" + final_run.error_message = None + db.query.return_value.filter.return_value.first.return_value = final_run + + result = await complete_success( + db, event_log, aggregator, sql_service, + run, job, skip_insert=False, + language_stats_map={}, current_user="u", + ) + + assert result.status == "COMPLETED" + sql_service.generate_and_insert_sql.assert_called_once_with(job, run) + assert result.insert_status == "success" + assert result.superset_execution_id == "q-1" + + @pytest.mark.asyncio + async def test_insert_failed_sets_status_failed(self): + """Insert status 'failed' -> run status FAILED.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + sql_service = AsyncMock() + sql_service.generate_and_insert_sql.return_value = { + "status": "failed", "error_message": "DB timeout", + } + run = self._make_run() + job = self._make_job() + final_run = MagicMock(status="FAILED") + final_run.error_message = "DB timeout" + db.query.return_value.filter.return_value.first.return_value = final_run + + result = await complete_success( + db, event_log, aggregator, sql_service, + run, job, skip_insert=False, + language_stats_map={}, current_user="u", + ) + + assert result.status == "FAILED" + assert "DB timeout" in result.error_message + + @pytest.mark.asyncio + async def test_insert_timeout_sets_status_failed(self): + """Insert status 'timeout' -> run status FAILED.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + sql_service = AsyncMock() + sql_service.generate_and_insert_sql.return_value = { + "status": "timeout", "error_message": None, + } + run = self._make_run() + job = self._make_job() + final_run = MagicMock(status="FAILED") + final_run.error_message = "SQL insert phase timeout" + db.query.return_value.filter.return_value.first.return_value = final_run + + result = await complete_success( + db, event_log, aggregator, sql_service, + run, job, skip_insert=False, + language_stats_map={}, current_user="u", + ) + + assert result.status == "FAILED" + assert "SQL insert phase timeout" in result.error_message + + @pytest.mark.asyncio + async def test_insert_skipped_non_failure(self): + """Insert status 'skipped' -> not a failure, COMPLETED.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + sql_service = AsyncMock() + sql_service.generate_and_insert_sql.return_value = { + "status": "skipped", "query_id": None, + } + run = self._make_run() + job = self._make_job() + final_run = MagicMock(status="COMPLETED") + db.query.return_value.filter.return_value.first.return_value = final_run + + result = await complete_success( + db, event_log, aggregator, sql_service, + run, job, skip_insert=False, + language_stats_map={}, current_user="u", + ) + + assert result.status == "COMPLETED" + + @pytest.mark.asyncio + async def test_insert_with_error_message_appended(self): + """error_message from insert appended to run.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + sql_service = AsyncMock() + sql_service.generate_and_insert_sql.return_value = { + "status": "success", "error_message": "warning: slow query", + } + run = self._make_run() + run.error_message = None + job = self._make_job() + final_run = MagicMock(status="COMPLETED") + final_run.error_message = "warning: slow query" + db.query.return_value.filter.return_value.first.return_value = final_run + + result = await complete_success( + db, event_log, aggregator, sql_service, + run, job, skip_insert=False, + language_stats_map={}, current_user="u", + ) + + assert result.error_message == "warning: slow query" + + @pytest.mark.asyncio + async def test_insert_failed_without_error_message_uses_default(self): + """Insert failed without error_message -> default message.""" + db = MagicMock() + event_log = MagicMock() + aggregator = MagicMock() + sql_service = AsyncMock() + sql_service.generate_and_insert_sql.return_value = { + "status": "failed", "error_message": None, + } + run = self._make_run() + run.error_message = None + job = self._make_job() + final_run = MagicMock(status="FAILED") + final_run.error_message = "SQL insert phase failed" + db.query.return_value.filter.return_value.first.return_value = final_run + + result = await complete_success( + db, event_log, aggregator, sql_service, + run, job, skip_insert=False, + language_stats_map={}, current_user="u", + ) + + assert "SQL insert phase failed" in result.error_message + + @pytest.mark.asyncio + async def test_refreshes_run_after_commit(self): + """Run is re-queried after commit.""" + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = MagicMock(status="COMPLETED") + event_log = MagicMock() + aggregator = MagicMock() + sql_service = AsyncMock() + sql_service.generate_and_insert_sql.return_value = { + "status": "success", "query_id": "q-1", + } + run = self._make_run() + job = self._make_job() + + result = await complete_success( + db, event_log, aggregator, sql_service, + run, job, skip_insert=False, + language_stats_map={}, current_user="u", + ) + + # Should re-query run after commit + db.query.assert_called() +# #endregion Test.OrchestratorRunCompletion diff --git a/backend/tests/plugins/translate/test_orchestrator_runner.py b/backend/tests/plugins/translate/test_orchestrator_runner.py new file mode 100644 index 00000000..cfb1ae4e --- /dev/null +++ b/backend/tests/plugins/translate/test_orchestrator_runner.py @@ -0,0 +1,86 @@ +# #region Test.OrchestratorRunner [C:3] [TYPE Module] [SEMANTICS test, translate, runner, execution] +# @BRIEF Tests for orchestrator_runner.py — TranslationStageRunner delegates to sub-components. +# @RELATION BINDS_TO -> [TranslationStageRunner] +# @TEST_CONTRACT: execute_run -> TranslationRun | delegates to TranslationExecutionEngine +# @TEST_CONTRACT: retry_failed_batches -> TranslationRun | delegates to TranslationRunRetryManager +# @TEST_CONTRACT: retry_insert -> TranslationRun | delegates to TranslationRunRetryManager +# @TEST_CONTRACT: cancel_run -> TranslationRun | delegates to TranslationRunRetryManager + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from unittest.mock import MagicMock, AsyncMock, patch + +import pytest + +from src.plugins.translate.orchestrator_runner import TranslationStageRunner + + +class TestTranslationStageRunner: + """TranslationStageRunner — delegates to sub-components.""" + + def _make_runner(self) -> tuple[TranslationStageRunner, MagicMock, MagicMock]: + """Create a runner with mocked sub-components.""" + db = MagicMock() + config_manager = MagicMock() + event_log = MagicMock() + + with patch( + "src.plugins.translate.orchestrator_runner.TranslationExecutionEngine" + ) as MockExec, patch( + "src.plugins.translate.orchestrator_runner.TranslationRunRetryManager" + ) as MockRetry: + runner = TranslationStageRunner(db, config_manager, event_log, "user") + mock_exec = MockExec.return_value + mock_retry = MockRetry.return_value + return runner, mock_exec, mock_retry + + @pytest.mark.asyncio + async def test_execute_run_delegates(self): + """execute_run delegates to TranslationExecutionEngine.""" + runner, mock_exec, _ = self._make_runner() + mock_run = MagicMock() + mock_exec.execute_run = AsyncMock(return_value=mock_run) + + result = await runner.execute_run(mock_run, on_batch_progress=None, skip_insert=False) + + mock_exec.execute_run.assert_called_once_with( + run=mock_run, on_batch_progress=None, skip_insert=False, + ) + assert result == mock_run + + @pytest.mark.asyncio + async def test_retry_failed_batches_delegates(self): + """retry_failed_batches delegates to TranslationRunRetryManager.""" + runner, _, mock_retry = self._make_runner() + mock_retry.retry_failed_batches = AsyncMock(return_value=MagicMock()) + + result = await runner.retry_failed_batches("run-1") + + mock_retry.retry_failed_batches.assert_called_once_with("run-1") + + @pytest.mark.asyncio + async def test_retry_insert_delegates(self): + """retry_insert delegates to TranslationRunRetryManager.""" + runner, _, mock_retry = self._make_runner() + mock_retry.retry_insert = AsyncMock(return_value=MagicMock()) + + result = await runner.retry_insert("run-1") + + mock_retry.retry_insert.assert_called_once_with("run-1") + + def test_cancel_run_delegates(self): + """cancel_run delegates to TranslationRunRetryManager.""" + runner, _, mock_retry = self._make_runner() + mock_retry.cancel_run.return_value = MagicMock() + + result = runner.cancel_run("run-1") + + mock_retry.cancel_run.assert_called_once_with("run-1") +# #endregion Test.OrchestratorRunner diff --git a/backend/tests/plugins/translate/test_preview_prompt_builder.py b/backend/tests/plugins/translate/test_preview_prompt_builder.py new file mode 100644 index 00000000..e1b5753f --- /dev/null +++ b/backend/tests/plugins/translate/test_preview_prompt_builder.py @@ -0,0 +1,340 @@ +# #region Test.PreviewPromptBuilder [C:3] [TYPE Module] [SEMANTICS test, translate, preview, prompt, builder] +# @BRIEF Tests for preview_prompt_builder.py — PreviewPromptBuilder.build_prompt_from_rows. +# @RELATION BINDS_TO -> [PreviewPromptBuilder] +# @TEST_CONTRACT: build_prompt_from_rows -> dict | builds LLM prompt with glossary +# @TEST_CONTRACT: estimate_token_budget_for_rows -> dict | delegates to helper +# @TEST_EDGE: dictionary_matches +# @TEST_EDGE: no_dictionary_matches +# @TEST_EDGE: custom_prompt_template + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +from unittest.mock import MagicMock, patch + +import pytest + +from src.plugins.translate.preview_prompt_builder import PreviewPromptBuilder + + +class TestBuildPromptFromRows: + """PreviewPromptBuilder.build_prompt_from_rows — builds prompt with dictionary glossary.""" + + def _make_job(self, **kwargs): + job = MagicMock() + job.id = "job-1" + job.source_dialect = "postgresql" + job.target_dialect = "clickhouse" + job.target_languages = ["ru", "de"] + job.translation_column = "name" + job.context_columns = ["description"] + for k, v in kwargs.items(): + setattr(job, k, v) + return job + + def _make_db(self): + return MagicMock() + + def test_build_prompt_basic(self): + """Builds prompt with row data and no dictionary section.""" + db = self._make_db() + job = self._make_job() + source_rows = [ + {"name": "hello", "description": "greeting"}, + {"name": "world", "description": "earth"}, + ] + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.return_value = "rendered prompt" + + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = { + "prompt": "rendered prompt", "row_meta": [], + "target_languages": ["ru", "de"], "num_languages": 2, + "dictionary_section": "", "sample_prompt_tokens": 100, + "sample_output_tokens": 200, "sample_total_tokens": 300, + "sample_cost": 0.001, "total_est_rows": 50, "total_est_tokens": 500, + "total_est_cost": 0.01, "cost_warning": None, "actual_row_count": 2, + } + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows(job, source_rows, sample_size=5) + + assert result["prompt"] == "rendered prompt" + assert result["sample_prompt_tokens"] == 100 + mock_filter.assert_called_once() + mock_render.assert_called_once() + + def test_build_prompt_with_dictionary(self): + """Dictionary matches produce glossary section.""" + db = self._make_db() + job = self._make_job() + source_rows = [{"name": "hello", "description": "greeting"}] + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [ + {"source_term": "hello", "target_term": "привет", "context_notes": "informal"}, + {"source_term": "world", "target_term": "мир", "context_notes": None}, + ] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.side_effect = lambda template, ctx: ctx.get("dictionary_section", "") + + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = { + "prompt": "", "row_meta": [], "target_languages": ["ru", "de"], + "num_languages": 2, "dictionary_section": "...", + "sample_prompt_tokens": 0, "sample_output_tokens": 0, + "sample_total_tokens": 0, "sample_cost": 0.0, + "total_est_rows": 10, "total_est_tokens": 0, "total_est_cost": 0.0, + "cost_warning": None, "actual_row_count": 1, + } + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows(job, source_rows, sample_size=1) + + # Verify dictionary was included in render + mock_render.assert_called_once() + variables = mock_render.call_args[0][1] + assert "Terminology dictionary" in variables["dictionary_section"] + assert "hello" in variables["dictionary_section"] + assert "привет" in variables["dictionary_section"] + assert "informal" in variables["dictionary_section"] + + def test_build_prompt_no_context_columns(self): + """Job without context columns still works.""" + db = self._make_db() + job = self._make_job(context_columns=None) + source_rows = [{"name": "hello"}] + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.return_value = "prompt" + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = {"prompt": "prompt"} + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows(job, source_rows, sample_size=1) + + assert result["prompt"] == "prompt" + + def test_custom_prompt_template(self): + """Custom prompt template overrides default.""" + db = self._make_db() + job = self._make_job() + source_rows = [{"name": "hello"}] + custom_template = "Custom: {source_language} -> {target_languages}" + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.return_value = "custom rendered" + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = {"prompt": "custom rendered"} + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows( + job, source_rows, sample_size=1, prompt_template=custom_template, + ) + + # Verify custom template was used + mock_render.assert_called_once() + assert mock_render.call_args[0][0] == custom_template + + def test_default_prompt_template(self): + """Default prompt template when none provided.""" + db = self._make_db() + job = self._make_job() + source_rows = [{"name": "hello"}] + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.return_value = "default rendered" + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = {"prompt": "default rendered"} + from src.plugins.translate.preview_constants import DEFAULT_PREVIEW_PROMPT_TEMPLATE + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows(job, source_rows, sample_size=1) + + assert mock_render.call_args[0][0] == DEFAULT_PREVIEW_PROMPT_TEMPLATE + + def test_string_target_languages_converted(self): + """String target_languages converted to list.""" + db = self._make_db() + job = self._make_job(target_languages="ru") # string, not list + source_rows = [{"name": "hello"}] + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.return_value = "prompt" + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = {"prompt": "prompt"} + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows(job, source_rows, sample_size=1) + + # Verify target_languages was converted to list + mock_render.assert_called_once() + variables = mock_render.call_args[0][1] + assert "ru" in variables["target_languages"] + + def test_empty_source_rows(self): + """Empty source rows handled.""" + db = self._make_db() + job = self._make_job() + source_rows = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.return_value = "prompt" + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = {"prompt": "prompt"} + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows(job, source_rows, sample_size=5) + + assert result["prompt"] == "prompt" + + def test_json_rows_format(self): + """Rows are serialized to JSON in prompt context.""" + db = self._make_db() + job = self._make_job() + source_rows = [{"name": "hello", "description": "greeting"}] + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.return_value = "prompt" + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = {"prompt": "prompt"} + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows(job, source_rows, sample_size=1) + + variables = mock_render.call_args[0][1] + assert "row_id" in variables["rows_json"] + assert "text" in variables["rows_json"] + assert "hello" in variables["rows_json"] + + def test_row_meta_contains_context(self): + """Row meta includes context_data from context columns.""" + db = self._make_db() + job = self._make_job(context_columns=["description", "category"]) + source_rows = [{"name": "hello", "description": "greeting", "category": "common"}] + + with patch( + "src.plugins.translate.preview_prompt_builder.DictionaryManager.filter_for_batch" + ) as mock_filter: + mock_filter.return_value = [] + + with patch( + "src.plugins.translate.preview_prompt_builder.render_prompt" + ) as mock_render: + mock_render.return_value = "prompt" + with patch( + "src.plugins.translate.preview_prompt_builder.compute_build_token_metadata" + ) as mock_meta: + mock_meta.return_value = {"prompt": "prompt"} + + builder = PreviewPromptBuilder(db) + result = builder.build_prompt_from_rows(job, source_rows, sample_size=1) + + # Verify the row_context was passed to filter_for_batch + mock_filter.assert_called_once() + filter_kwargs = mock_filter.call_args[1] + assert filter_kwargs["row_context"] == {"description": "greeting", "category": "common"} + + +class TestEstimateTokenBudgetForRows: + """PreviewPromptBuilder.estimate_token_budget_for_rows — delegates to helper.""" + + def test_delegates_to_helper(self): + """Calls estimate_token_budget_for_rows helper.""" + db = MagicMock() + job = MagicMock() + source_rows = [{"name": "hello"}] + + with patch( + "src.plugins.translate.preview_prompt_builder.estimate_token_budget_for_rows" + ) as mock_helper: + mock_helper.return_value = {"actual_row_count": 1, "source_rows": source_rows} + + builder = PreviewPromptBuilder(db) + result = builder.estimate_token_budget_for_rows( + source_rows, ["ru"], job, "gpt-4o-mini", + ) + + mock_helper.assert_called_once_with( + source_rows=source_rows, target_languages=["ru"], + job=job, provider_model="gpt-4o-mini", + ) + assert result == {"actual_row_count": 1, "source_rows": source_rows} +# #endregion Test.PreviewPromptBuilder diff --git a/backend/tests/plugins/translate/test_preview_token_estimator.py b/backend/tests/plugins/translate/test_preview_token_estimator.py new file mode 100644 index 00000000..bd043260 --- /dev/null +++ b/backend/tests/plugins/translate/test_preview_token_estimator.py @@ -0,0 +1,135 @@ +# #region Test.PreviewTokenEstimator [C:2] [TYPE Module] [SEMANTICS test, translate, token, cost, estimation] +# @BRIEF Tests for preview_token_estimator.py — TokenEstimator static methods. +# @RELATION BINDS_TO -> [TokenEstimator] +# @TEST_CONTRACT: TokenEstimator.estimate_prompt_tokens -> int | token estimate from string +# @TEST_CONTRACT: TokenEstimator.estimate_output_tokens -> int | output token estimate +# @TEST_CONTRACT: TokenEstimator.estimate_cost -> float | cost estimate from tokens +# @TEST_CONTRACT: TokenEstimator.check_cost_warning -> str | None | cost threshold warning + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import os +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") + +import pytest + +from src.plugins.translate.preview_token_estimator import TokenEstimator + + +class TestEstimatePromptTokens: + """TokenEstimator.estimate_prompt_tokens — estimate tokens from string.""" + + def test_empty_string_returns_zero(self): + """Empty string returns 0.""" + assert TokenEstimator.estimate_prompt_tokens("") == 0 + + def test_short_string_returns_at_least_one(self): + """Short string returns at least 1.""" + assert TokenEstimator.estimate_prompt_tokens("a") == 1 + + def test_long_string_estimates(self): + """Longer string produces token estimate.""" + text = "hello world " * 100 # ~1300 chars + tokens = TokenEstimator.estimate_prompt_tokens(text) + assert tokens > 0 + assert tokens <= len(text) # tokens shouldn't exceed chars + + def test_empty_string_handled(self): + """Empty string returns 0. Whitespace is truthy so gets min 1.""" + assert TokenEstimator.estimate_prompt_tokens("") == 0 + assert TokenEstimator.estimate_prompt_tokens(" ") == 1 + + +class TestEstimateOutputTokens: + """TokenEstimator.estimate_output_tokens — estimate output tokens.""" + + def test_zero_rows_returns_zero(self): + """0 rows returns 0.""" + assert TokenEstimator.estimate_output_tokens(0) == 0 + + def test_single_row_single_language(self): + """1 row, 1 language gives output estimate.""" + result = TokenEstimator.estimate_output_tokens(1, 1) + expected = int(1 * 1 * 120 * 1.2) + assert result == expected + + def test_multi_row_single_language(self): + """10 rows, 1 language.""" + result = TokenEstimator.estimate_output_tokens(10, 1) + expected = int(10 * 1 * 120 * 1.2) + assert result == expected + + def test_multi_row_multi_language(self): + """5 rows, 3 languages.""" + result = TokenEstimator.estimate_output_tokens(5, 3) + expected = int(5 * 3 * 120 * 1.2) + assert result == expected + + def test_default_language(self): + """Default num_languages is 1.""" + result = TokenEstimator.estimate_output_tokens(2) + expected = int(2 * 1 * 120 * 1.2) + assert result == expected + + +class TestEstimateCost: + """TokenEstimator.estimate_cost — cost from tokens.""" + + def test_zero_tokens_zero_cost(self): + """0 tokens costs 0.""" + assert TokenEstimator.estimate_cost(0) == 0.0 + + def test_default_rate(self): + """1000 tokens at default rate.""" + cost = TokenEstimator.estimate_cost(1000) + assert cost == pytest.approx(0.002, 0.000001) + + def test_custom_rate(self): + """Custom cost per 1k tokens.""" + cost = TokenEstimator.estimate_cost(1000, 0.01) + assert cost == pytest.approx(0.01, 0.000001) + + def test_large_token_count(self): + """Large token count calculates correctly.""" + cost = TokenEstimator.estimate_cost(100000) + assert cost == pytest.approx(0.2, 0.000001) + + def test_rounding_precision(self): + """Result is rounded to 6 decimal places.""" + cost = TokenEstimator.estimate_cost(333) + assert isinstance(cost, float) + + +class TestCheckCostWarning: + """TokenEstimator.check_cost_warning — cost threshold warning.""" + + def test_below_threshold_no_warning(self): + """sample_size <= 30 returns None.""" + assert TokenEstimator.check_cost_warning(10, 1, 100, 0.001) is None + assert TokenEstimator.check_cost_warning(30, 1, 100, 0.001) is None + + def test_above_threshold_returns_warning(self): + """sample_size > 30 returns warning string.""" + warning = TokenEstimator.check_cost_warning(50, 2, 5000, 0.05) + assert warning is not None + assert "Large preview" in warning + assert "5000 tokens" in warning + assert "$0.0500" in warning + assert "2 languages" in warning + + def test_warning_format_high_cost(self): + """Warning includes formatted cost.""" + warning = TokenEstimator.check_cost_warning(100, 1, 10000, 0.5) + assert warning is not None + assert "$0.5000" in warning + + def test_single_language_format(self): + """Warning for single language.""" + warning = TokenEstimator.check_cost_warning(40, 1, 2000, 0.004) + assert warning is not None + assert "1 languages" in warning +# #endregion Test.PreviewTokenEstimator diff --git a/backend/tests/services/clean_release/test_audit_service.py b/backend/tests/services/clean_release/test_audit_service.py new file mode 100644 index 00000000..35786981 --- /dev/null +++ b/backend/tests/services/clean_release/test_audit_service.py @@ -0,0 +1,160 @@ +# #region Test.CleanRelease.AuditService [C:3] [TYPE Module] [SEMANTICS test,clean-release,audit] +# @BRIEF Tests for audit_service.py — audit_preparation, audit_check_run, audit_violation, audit_report. +# @RELATION BINDS_TO -> [AuditService] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +from unittest.mock import MagicMock, patch +import pytest + + +# #region TestAuditPreparation [C:2] [TYPE Module] +class TestAuditPreparation: + def test_audit_preparation_logs_and_appends(self): + from src.services.clean_release.audit_service import audit_preparation + repo = MagicMock() + repo.append_audit_event = MagicMock() + + with patch("src.services.clean_release.audit_service.logger") as mock_logger: + audit_preparation( + candidate_id="cand-1", + status="PREPARED", + repository=repo, + actor="tester", + ) + mock_logger.reason.assert_called_once() + repo.append_audit_event.assert_called_once() + event = repo.append_audit_event.call_args[0][0] + assert event["action"] == "PREPARATION" + assert event["candidate_id"] == "cand-1" + assert event["actor"] == "tester" + assert event["status"] == "PREPARED" + + def test_audit_preparation_no_repo(self): + from src.services.clean_release.audit_service import audit_preparation + with patch("src.services.clean_release.audit_service.logger") as mock_logger: + audit_preparation(candidate_id="cand-2", status="FAILED") + mock_logger.reason.assert_called_once() + + +# #endregion TestAuditPreparation + + +# #region TestAuditCheckRun [C:2] [TYPE Module] +class TestAuditCheckRun: + def test_audit_check_run_logs_and_appends(self): + from src.services.clean_release.audit_service import audit_check_run + repo = MagicMock() + repo.append_audit_event = MagicMock() + + with patch("src.services.clean_release.audit_service.logger") as mock_logger: + audit_check_run( + check_run_id="run-1", + final_status="COMPLIANT", + repository=repo, + candidate_id="cand-1", + actor="admin", + ) + mock_logger.reflect.assert_called_once() + repo.append_audit_event.assert_called_once() + event = repo.append_audit_event.call_args[0][0] + assert event["action"] == "CHECK_RUN" + assert event["run_id"] == "run-1" + assert event["candidate_id"] == "cand-1" + + def test_audit_check_run_no_candidate(self): + from src.services.clean_release.audit_service import audit_check_run + repo = MagicMock() + repo.append_audit_event = MagicMock() + + with patch("src.services.clean_release.audit_service.logger") as mock_logger: + audit_check_run( + check_run_id="run-2", + final_status="BLOCKED", + repository=repo, + ) + repo.append_audit_event.assert_called_once() + event = repo.append_audit_event.call_args[0][0] + assert event["candidate_id"] is None + + +# #endregion TestAuditCheckRun + + +# #region TestAuditViolation [C:2] [TYPE Module] +class TestAuditViolation: + def test_audit_violation_logs_explore(self): + """Cover audit_violation lines 79-82.""" + from src.services.clean_release.audit_service import audit_violation + repo = MagicMock() + repo.append_audit_event = MagicMock() + + with patch("src.services.clean_release.audit_service.logger") as mock_logger: + audit_violation( + run_id="run-1", + stage_name="DATA_PURITY", + code="POL001", + repository=repo, + candidate_id="cand-1", + actor="tester", + ) + mock_logger.explore.assert_called_once() + repo.append_audit_event.assert_called_once() + event = repo.append_audit_event.call_args[0][0] + assert event["action"] == "VIOLATION" + assert event["run_id"] == "run-1" + assert event["stage_name"] == "DATA_PURITY" + assert event["code"] == "POL001" + + def test_audit_violation_no_repo(self): + """audit_violation without repository does not crash.""" + from src.services.clean_release.audit_service import audit_violation + with patch("src.services.clean_release.audit_service.logger") as mock_logger: + audit_violation( + run_id="run-2", + stage_name="SOURCE_ISOLATION", + code="EXT001", + ) + mock_logger.explore.assert_called_once() + + +# #endregion TestAuditViolation + + +# #region TestAuditReport [C:2] [TYPE Module] +class TestAuditReport: + def test_audit_report_logs_and_appends(self): + from src.services.clean_release.audit_service import audit_report + repo = MagicMock() + repo.append_audit_event = MagicMock() + + with patch("src.services.clean_release.audit_service.logger") as mock_logger: + audit_report( + report_id="report-1", + candidate_id="cand-1", + repository=repo, + run_id="run-1", + actor="tester", + ) + mock_logger.explore.assert_called_once() + repo.append_audit_event.assert_called_once() + event = repo.append_audit_event.call_args[0][0] + assert event["action"] == "REPORT" + assert event["report_id"] == "report-1" + + def test_audit_report_no_repo_does_not_crash(self): + from src.services.clean_release.audit_service import audit_report + with patch("src.services.clean_release.audit_service.logger") as mock_logger: + audit_report( + report_id="report-2", + candidate_id="cand-2", + ) + mock_logger.explore.assert_called_once() + + +# #endregion TestAuditReport + +# #endregion Test.CleanRelease.AuditService diff --git a/backend/tests/services/clean_release/test_candidate_manifest_services.py b/backend/tests/services/clean_release/test_candidate_manifest_services.py index a74c41cf..189cd495 100644 --- a/backend/tests/services/clean_release/test_candidate_manifest_services.py +++ b/backend/tests/services/clean_release/test_candidate_manifest_services.py @@ -297,4 +297,125 @@ def test_manifest_service_rejects_missing_candidate(): # #endregion test_manifest_service_rejects_missing_candidate + + +# #region test_manifest_service_rejects_empty_candidate_id [C:2] [TYPE Function] +def test_manifest_service_rejects_empty_candidate_id(): + """Empty candidate_id → ValueError (line 36).""" + from src.services.clean_release.manifest_service import build_manifest_snapshot + from src.services.clean_release.repository import CleanReleaseRepository + + repository = CleanReleaseRepository() + with pytest.raises(ValueError, match="candidate_id must be non-empty"): + build_manifest_snapshot( + repository=repository, + candidate_id=" ", + created_by="operator", + ) +# #endregion test_manifest_service_rejects_empty_candidate_id + + +# #region test_manifest_service_rejects_empty_created_by [C:2] [TYPE Function] +def test_manifest_service_rejects_empty_created_by(): + """Empty created_by → ValueError (line 38).""" + from src.services.clean_release.manifest_service import build_manifest_snapshot + from src.services.clean_release.repository import CleanReleaseRepository + + repository = CleanReleaseRepository() + with pytest.raises(ValueError, match="created_by must be non-empty"): + build_manifest_snapshot( + repository=repository, + candidate_id="cand-1", + created_by=" ", + ) +# #endregion test_manifest_service_rejects_empty_created_by + + +# #region test_manifest_service_rejects_wrong_status [C:2] [TYPE Function] +def test_manifest_service_rejects_wrong_status(): + """Candidate with wrong status → ValueError (line 48).""" + from src.services.clean_release.manifest_service import build_manifest_snapshot + from src.services.clean_release.repository import CleanReleaseRepository + from src.models.clean_release import ReleaseCandidate + from src.services.clean_release.enums import CandidateStatus + + repository = CleanReleaseRepository() + # Register in DRAFT (not PREPARED or MANIFEST_BUILT) + repository.save_candidate(ReleaseCandidate( + id="cand-wrong-status", version="1.0.0", + source_snapshot_ref="git:sha1", created_by="tester", + status=CandidateStatus.DRAFT.value, + )) + + with pytest.raises(ValueError, match="must be PREPARED or MANIFEST_BUILT"): + build_manifest_snapshot( + repository=repository, + candidate_id="cand-wrong-status", + created_by="operator", + ) +# #endregion test_manifest_service_rejects_wrong_status + + +# #region test_manifest_service_rejects_no_artifacts [C:2] [TYPE Function] +def test_manifest_service_rejects_no_artifacts(): + """No artifacts → ValueError (line 54).""" + from src.services.clean_release.manifest_service import build_manifest_snapshot + from src.services.clean_release.repository import CleanReleaseRepository + from src.models.clean_release import ReleaseCandidate + from src.services.clean_release.enums import CandidateStatus + + repository = CleanReleaseRepository() + repository.save_candidate(ReleaseCandidate( + id="cand-no-artifacts", version="1.0.0", + source_snapshot_ref="git:sha1", created_by="tester", + status=CandidateStatus.PREPARED.value, + )) + + with pytest.raises(ValueError, match="artifacts are required"): + build_manifest_snapshot( + repository=repository, + candidate_id="cand-no-artifacts", + created_by="operator", + ) +# #endregion test_manifest_service_rejects_no_artifacts + + +# #region test_manifest_service_rejects_existing_non_immutable [C:2] [TYPE Function] +def test_manifest_service_rejects_existing_non_immutable(): + """Existing manifest with immutable=False → ValueError (line 59).""" + from src.services.clean_release.manifest_service import build_manifest_snapshot + from src.services.clean_release.repository import CleanReleaseRepository + from src.models.clean_release import DistributionManifest, CandidateArtifact, ReleaseCandidate + from src.services.clean_release.enums import CandidateStatus + from datetime import UTC, datetime + + repository = CleanReleaseRepository() + repository.save_candidate(ReleaseCandidate( + id="cand-bad-manifest", version="1.0.0", + source_snapshot_ref="git:sha1", created_by="tester", + status=CandidateStatus.PREPARED.value, + )) + # Add an artifact so it passes the artifacts check + repository.save_artifact(CandidateArtifact( + id="art-1", candidate_id="cand-bad-manifest", + path="bin/app", sha256="abc123", size=42, + )) + # Add an existing manifest that is NOT immutable + repository.save_manifest(DistributionManifest( + id="manifest-existing", candidate_id="cand-bad-manifest", + manifest_version=1, manifest_digest="d1", artifacts_digest="d1", + source_snapshot_ref="ref1", content_json={}, + created_at=datetime.now(UTC), created_by="tester", + immutable=False, # violation! + )) + + with pytest.raises(ValueError, match="immutability invariant violated"): + build_manifest_snapshot( + repository=repository, + candidate_id="cand-bad-manifest", + created_by="operator", + ) +# #endregion test_manifest_service_rejects_existing_non_immutable + + # #endregion test_candidate_manifest_services diff --git a/backend/tests/services/clean_release/test_compliance_orchestrator.py b/backend/tests/services/clean_release/test_compliance_orchestrator.py index 9247d14f..2c5672c9 100644 --- a/backend/tests/services/clean_release/test_compliance_orchestrator.py +++ b/backend/tests/services/clean_release/test_compliance_orchestrator.py @@ -104,6 +104,23 @@ class TestStartCheckRun: assert run is not None assert run.requested_by == "system" + def test_start_with_policy_none(self): + """Policy lookup returns None — should log explore but not raise (line 108).""" + repo = _make_repo() + repo.get_policy.return_value = None + from unittest.mock import patch + from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator + + orch = CleanComplianceOrchestrator(repo) + with patch.object(orch.repository, "get_policy", return_value=None) as mock_get: + run = orch.start_check_run( + candidate_id="cand-1", + policy_id="policy-missing", + requested_by="tester", + ) + assert run is not None + assert run.policy_snapshot_id == "policy-missing" + # ── execute_stages ── @@ -253,6 +270,54 @@ class TestExecuteStages: assert result.status == "SUCCEEDED" assert result.final_status == "PASSED" + def test_forced_with_non_compliance_stage_run_objects(self): + """Forced results that are not ComplianceStageRun instances (lines 166-173).""" + from src.services.clean_release.enums import ComplianceStageName, ComplianceDecision + from src.models.clean_release import CheckStageName, CheckStageStatus, CheckStageResult + + repo = _make_repo() + run = MagicMock() + run.id = "check-1" + run.candidate_id = "cand-1" + run.status = "RUNNING" + + # Use CheckStageResult objects (not ComplianceStageRun) to hit the else branch + forced = [ + CheckStageResult(stage=CheckStageName.DATA_PURITY, status=CheckStageStatus.PASS), + CheckStageResult(stage=CheckStageName.INTERNAL_SOURCES_ONLY, status=CheckStageStatus.PASS), + CheckStageResult(stage=CheckStageName.NO_EXTERNAL_ENDPOINTS, status=CheckStageStatus.FAIL), + CheckStageResult(stage=CheckStageName.MANIFEST_CONSISTENCY, status=CheckStageStatus.PASS), + ] + + from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator + orch = CleanComplianceOrchestrator(repo) + result = orch.execute_stages(run, forced_results=forced) + assert result.status == "SUCCEEDED" + assert result.final_status == "BLOCKED" + assert len(repo.stage_runs) == 4 + + def test_forced_with_non_compliance_stage_run_error_status(self): + """Forced result with unknown status hits the 'else' in the status check.""" + from src.services.clean_release.enums import ComplianceStageName, ComplianceDecision + from src.models.clean_release import CheckStageName, CheckStageStatus, CheckStageResult + + repo = _make_repo() + run = MagicMock() + run.id = "check-1" + run.candidate_id = "cand-1" + run.status = "RUNNING" + + forced = [ + CheckStageResult(stage=CheckStageName.DATA_PURITY, status=CheckStageStatus.RUNNING), + ] + + from src.services.clean_release.compliance_orchestrator import CleanComplianceOrchestrator + orch = CleanComplianceOrchestrator(repo) + result = orch.execute_stages(run, forced_results=forced) + assert result.status == "SUCCEEDED" + # RUNNING status is neither PASS nor FAIL -> decision becomes ERROR -> stage SKIPPED -> FAILED + assert result.final_status == "FAILED" + # ── finalize_run ── diff --git a/backend/tests/services/clean_release/test_manifest_builder.py b/backend/tests/services/clean_release/test_manifest_builder.py new file mode 100644 index 00000000..4c7ed6a8 --- /dev/null +++ b/backend/tests/services/clean_release/test_manifest_builder.py @@ -0,0 +1,82 @@ +# #region Test.CleanRelease.ManifestBuilder [C:3] [TYPE Module] [SEMANTICS test,clean-release,manifest,builder] +# @BRIEF Tests for manifest_builder.py — build_distribution_manifest and build_manifest legacy wrapper. +# @RELATION BINDS_TO -> [ManifestBuilder] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import pytest + + +# #region TestBuildDistributionManifest [C:2] [TYPE Module] +class TestBuildDistributionManifest: + def test_builds_manifest(self): + from src.services.clean_release.manifest_builder import build_distribution_manifest + artifacts = [ + {"path": "bin/app", "category": "app", "classification": "allowed", "reason": "test", "checksum": "abc"}, + {"path": "lib/vuln.dll", "category": "malware", "classification": "excluded-prohibited", "reason": "blocked", "checksum": "def"}, + ] + manifest = build_distribution_manifest( + manifest_id="man-1", + candidate_id="cand-1", + policy_id="policy-1", + generated_by="tester", + artifacts=artifacts, + ) + assert manifest.manifest_id == "man-1" + assert manifest.candidate_id == "cand-1" + assert len(manifest.content_json.get("items", [])) == 2 + assert manifest.summary.included_count == 1 + assert manifest.summary.excluded_count == 1 + assert manifest.summary.prohibited_detected_count == 1 + assert manifest.deterministic_hash is not None + + def test_builds_manifest_without_checksum(self): + from src.services.clean_release.manifest_builder import build_distribution_manifest + artifacts = [ + {"path": "bin/app", "category": "app", "classification": "required-system", "reason": "system"}, + ] + manifest = build_distribution_manifest( + manifest_id="man-2", + candidate_id="cand-2", + policy_id="policy-2", + generated_by="tester", + artifacts=artifacts, + ) + items = manifest.content_json.get("items", []) + assert len(items) == 1 + assert items[0].get("checksum") is None + assert manifest.summary.included_count == 1 + assert manifest.summary.excluded_count == 0 + + +# #endregion TestBuildDistributionManifest + + +# #region TestBuildManifestLegacy [C:2] [TYPE Module] +class TestBuildManifestLegacy: + def test_build_manifest_legacy_delegates(self): + """build_manifest legacy wrapper (line 129) delegates to build_distribution_manifest.""" + from src.services.clean_release.manifest_builder import build_distribution_manifest, build_manifest + artifacts = [ + {"path": "bin/app", "category": "app", "classification": "allowed", "reason": "test", "checksum": "abc"}, + ] + m1 = build_distribution_manifest( + manifest_id="man-3", candidate_id="cand-3", + policy_id="policy-3", generated_by="tester", + artifacts=artifacts, + ) + m2 = build_manifest( + manifest_id="man-3", candidate_id="cand-3", + policy_id="policy-3", generated_by="tester", + artifacts=artifacts, + ) + assert m1.manifest_id == m2.manifest_id + assert m1.deterministic_hash == m2.deterministic_hash + + +# #endregion TestBuildManifestLegacy + +# #endregion Test.CleanRelease.ManifestBuilder diff --git a/backend/tests/services/clean_release/test_policy_resolution_service.py b/backend/tests/services/clean_release/test_policy_resolution_service.py index b4f9ebbc..691d4407 100644 --- a/backend/tests/services/clean_release/test_policy_resolution_service.py +++ b/backend/tests/services/clean_release/test_policy_resolution_service.py @@ -117,4 +117,147 @@ def test_resolve_trusted_policy_snapshots_rejects_override_attempt(): # #endregion test_resolve_trusted_policy_snapshots_rejects_override_attempt + +# #region test_missing_clean_release_settings [C:2] [TYPE Function] +def test_missing_clean_release_settings(): + """clean_release_settings missing → PolicyResolutionError (line 44).""" + repository = CleanReleaseRepository() + # settings has no clean_release attribute + config = SimpleNamespace(settings=SimpleNamespace()) + config_manager = SimpleNamespace(get_config=lambda: config) + + with pytest.raises(PolicyResolutionError, match="clean_release settings are missing"): + resolve_trusted_policy_snapshots( + config_manager=config_manager, + repository=repository, + ) +# #endregion test_missing_clean_release_settings + + +# #region test_policy_not_found [C:2] [TYPE Function] +def test_policy_not_found(): + """get_policy returns None → PolicyResolutionError (line 60).""" + repository = CleanReleaseRepository() + config_manager = _config_manager(policy_id="policy-missing", registry_id="registry-1") + + with pytest.raises(PolicyResolutionError, match="trusted policy snapshot.*not found"): + resolve_trusted_policy_snapshots( + config_manager=config_manager, + repository=repository, + ) +# #endregion test_policy_not_found + + +# #region test_registry_not_found [C:2] [TYPE Function] +def test_registry_not_found(): + """get_registry returns None → PolicyResolutionError (line 66).""" + repository = CleanReleaseRepository() + repository.save_policy( + CleanPolicySnapshot( + id="policy-1", policy_id="policy-1", policy_version="1.0.0", + content_json={"rules": []}, + registry_snapshot_id="registry-missing", + immutable=True, + ) + ) + config_manager = _config_manager(policy_id="policy-1", registry_id="registry-missing") + + with pytest.raises(PolicyResolutionError, match="trusted registry snapshot.*not found"): + resolve_trusted_policy_snapshots( + config_manager=config_manager, + repository=repository, + ) +# #endregion test_registry_not_found + + +# #region test_policy_not_immutable [C:2] [TYPE Function] +def test_policy_not_immutable(): + """Policy snapshot not immutable → PolicyResolutionError (line 71).""" + repository = CleanReleaseRepository() + repository.save_policy( + CleanPolicySnapshot( + id="policy-1", policy_id="policy-1", policy_version="1.0.0", + content_json={"rules": []}, + registry_snapshot_id="registry-1", + immutable=False, # not immutable + ) + ) + repository.save_registry( + SourceRegistrySnapshot( + id="registry-1", registry_id="registry-1", registry_version="1.0.0", + allowed_hosts=["internal.local"], allowed_schemes=["https"], + allowed_source_types=["repo"], immutable=True, + ) + ) + config_manager = _config_manager(policy_id="policy-1", registry_id="registry-1") + + with pytest.raises(PolicyResolutionError, match="policy snapshot must be immutable"): + resolve_trusted_policy_snapshots( + config_manager=config_manager, + repository=repository, + ) +# #endregion test_policy_not_immutable + + +# #region test_registry_not_immutable [C:2] [TYPE Function] +def test_registry_not_immutable(): + """Registry snapshot not immutable → PolicyResolutionError (line 73).""" + repository = CleanReleaseRepository() + repository.save_policy( + CleanPolicySnapshot( + id="policy-1", policy_id="policy-1", policy_version="1.0.0", + content_json={"rules": []}, + registry_snapshot_id="registry-1", + immutable=True, + ) + ) + repository.save_registry( + SourceRegistrySnapshot( + id="registry-1", registry_id="registry-1", registry_version="1.0.0", + allowed_hosts=["internal.local"], allowed_schemes=["https"], + allowed_source_types=["repo"], + immutable=False, # not immutable + ) + ) + config_manager = _config_manager(policy_id="policy-1", registry_id="registry-1") + + with pytest.raises(PolicyResolutionError, match="registry snapshot must be immutable"): + resolve_trusted_policy_snapshots( + config_manager=config_manager, + repository=repository, + ) +# #endregion test_registry_not_immutable + + +# #region test_full_success_path [C:2] [TYPE Function] +def test_full_success_path(): + """All valid inputs → returns (policy, registry) tuple (line 75).""" + repository = CleanReleaseRepository() + policy = CleanPolicySnapshot( + id="policy-1", policy_id="policy-1", policy_version="1.0.0", + content_json={"rules": ["allow-all"]}, + registry_snapshot_id="registry-1", + immutable=True, + ) + registry = SourceRegistrySnapshot( + id="registry-1", registry_id="registry-1", registry_version="1.0.0", + allowed_hosts=["internal.local"], allowed_schemes=["https"], + allowed_source_types=["repo"], immutable=True, + ) + repository.save_policy(policy) + repository.save_registry(registry) + + config_manager = _config_manager(policy_id="policy-1", registry_id="registry-1") + + policy_result, registry_result = resolve_trusted_policy_snapshots( + config_manager=config_manager, + repository=repository, + ) + assert policy_result.id == "policy-1" + assert policy_result.immutable is True + assert registry_result.id == "registry-1" + assert registry_result.immutable is True +# #endregion test_full_success_path + + # #endregion TestPolicyResolutionService diff --git a/backend/tests/services/clean_release/test_preparation_service.py b/backend/tests/services/clean_release/test_preparation_service.py index d101e4a6..b6bbe71c 100644 --- a/backend/tests/services/clean_release/test_preparation_service.py +++ b/backend/tests/services/clean_release/test_preparation_service.py @@ -165,3 +165,75 @@ class TestPrepareCandidate: ) assert result_legacy["status"] == result_canonical["status"] # #endregion test_preparation_legacy_wrapper + + # #region test_registry_not_found_raises [C:2] [TYPE Function] + def test_registry_not_found_raises(self): + """Registry lookup returns None → ValueError (line 44).""" + from src.services.clean_release.repository import CleanReleaseRepository + from src.models.clean_release import ReleaseCandidate, CleanPolicySnapshot + from src.services.clean_release.enums import CandidateStatus + + repo = CleanReleaseRepository() + repo.save_candidate(ReleaseCandidate( + id="cand-noregistry", version="1.0.0", source_snapshot_ref="git:sha1", + created_by="tester", created_at=datetime.now(UTC), + status=CandidateStatus.DRAFT.value, + )) + # Policy references a registry_id that does not exist in repository + repo.save_policy(CleanPolicySnapshot( + id="policy-noreg", policy_id="policy-noreg", policy_version="1", + content_json={ + "profile": "enterprise-clean", + "prohibited_artifact_categories": ["malware"], + "required_system_categories": [], + "external_source_forbidden": True, + }, + registry_snapshot_id="registry-nonexistent", + immutable=True, + )) + + with pytest.raises(ValueError, match="Registry not found"): + prepare_candidate( + repository=repo, candidate_id="cand-noregistry", + artifacts=[{"path": "bin/app", "category": "generic", "reason": "test", "checksum": "abc"}], + sources=[], operator_id="tester", + ) + # #endregion test_registry_not_found_raises + + # #region test_invalid_policy_raises [C:2] [TYPE Function] + def test_invalid_policy_raises(self): + """Policy validation fails → ValueError (line 49).""" + from src.services.clean_release.repository import CleanReleaseRepository + from src.models.clean_release import ReleaseCandidate, CleanPolicySnapshot, SourceRegistrySnapshot + from src.services.clean_release.enums import CandidateStatus + + repo = CleanReleaseRepository() + repo.save_candidate(ReleaseCandidate( + id="cand-badpolicy", version="1.0.0", source_snapshot_ref="git:sha1", + created_by="tester", created_at=datetime.now(UTC), + status=CandidateStatus.DRAFT.value, + )) + # Create policy with missing profile settings to make validate_policy fail + repo.save_policy(CleanPolicySnapshot( + id="policy-invalid", policy_id="policy-invalid", policy_version="1", + content_json={ + "profile": "enterprise-clean", + # Missing prohibited_artifact_categories — should fail validation + "external_source_forbidden": True, + }, + registry_snapshot_id="registry-invalid", + immutable=True, + )) + repo.save_registry(SourceRegistrySnapshot( + id="registry-invalid", registry_id="registry-invalid", + registry_version="1", allowed_hosts=["internal.local"], + immutable=True, + )) + + with pytest.raises(ValueError, match="Invalid policy"): + prepare_candidate( + repository=repo, candidate_id="cand-badpolicy", + artifacts=[{"path": "bin/app", "category": "generic", "reason": "test", "checksum": "abc"}], + sources=[], operator_id="tester", + ) + # #endregion test_invalid_policy_raises diff --git a/backend/tests/services/clean_release/test_report_builder.py b/backend/tests/services/clean_release/test_report_builder.py new file mode 100644 index 00000000..0810c44e --- /dev/null +++ b/backend/tests/services/clean_release/test_report_builder.py @@ -0,0 +1,121 @@ +# #region Test.CleanRelease.ReportBuilder [C:3] [TYPE Module] [SEMANTICS test,clean-release,report,builder,compliance] +# @BRIEF Tests for report_builder.py — ComplianceReportBuilder.build_report_payload, persist_report. +# @RELATION BINDS_TO -> [ReportBuilder] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +from datetime import UTC, datetime +from unittest.mock import MagicMock +import pytest + + +# #region TestBuildReportPayload [C:2] [TYPE Module] +class TestBuildReportPayload: + @pytest.fixture + def repo(self): + return MagicMock() + + @pytest.fixture + def builder(self, repo): + from src.services.clean_release.report_builder import ComplianceReportBuilder + return ComplianceReportBuilder(repo) + + def test_raises_for_running_run(self, builder): + """Non-terminal (RUNNING) run raises ValueError (line 37).""" + from src.services.clean_release.enums import RunStatus + run = MagicMock() + run.status = RunStatus.RUNNING + with pytest.raises(ValueError, match="non-terminal"): + builder.build_report_payload(run, []) + + def test_raises_for_blocked_with_no_violations(self, builder): + """BLOCKED final_status with zero blocking violations raises ValueError.""" + from src.services.clean_release.enums import ComplianceDecision, RunStatus + run = MagicMock() + run.status = RunStatus.SUCCEEDED + run.final_status = ComplianceDecision.BLOCKED + violations = [] + with pytest.raises(ValueError, match="blocking violation"): + builder.build_report_payload(run, violations) + + def test_raises_for_blocked_with_non_blocking_violations(self, builder): + """BLOCKED run with violations but none blocking raises ValueError.""" + from src.services.clean_release.enums import ComplianceDecision, RunStatus + from src.models.clean_release import ComplianceViolation + run = MagicMock() + run.status = RunStatus.SUCCEEDED + run.final_status = ComplianceDecision.BLOCKED + violations = [ + ComplianceViolation( + id="v1", run_id="run-1", stage_name="DATA_PURITY", + code="WARN001", message="warning", severity="MINOR", + evidence_json={"blocked_release": False}, + ) + ] + with pytest.raises(ValueError, match="blocking violation"): + builder.build_report_payload(run, violations) + + def test_returns_report_for_passed_run(self, builder): + """PASSED run returns ComplianceReport with correct summary.""" + from src.services.clean_release.enums import ComplianceDecision, RunStatus + from src.models.clean_release import ComplianceViolation + run = MagicMock() + run.id = "check-run-1" + run.candidate_id = "cand-1" + run.status = RunStatus.SUCCEEDED + run.final_status = ComplianceDecision.PASSED + + report = builder.build_report_payload(run, []) + assert report is not None + assert report.run_id == "check-run-1" + assert report.final_status == ComplianceDecision.PASSED + assert report.immutable is True + assert "no blocking violations" in report.summary_json["operator_summary"] + + def test_returns_report_for_blocked_run(self, builder): + """BLOCKED run with blocking violations returns report.""" + from src.services.clean_release.enums import ComplianceDecision, RunStatus + from src.models.clean_release import ComplianceViolation + run = MagicMock() + run.id = "check-run-2" + run.candidate_id = "cand-2" + run.status = RunStatus.SUCCEEDED + run.final_status = ComplianceDecision.BLOCKED + + violations = [ + ComplianceViolation( + id="v1", run_id="run-1", stage_name="DATA_PURITY", + code="POL001", message="prohibited artifact", severity="CRITICAL", + evidence_json={"blocked_release": True}, + ) + ] + report = builder.build_report_payload(run, violations) + assert report is not None + assert report.summary_json["violations_count"] == 1 + assert report.summary_json["blocking_violations_count"] == 1 + assert "Blocked" in report.summary_json["operator_summary"] + + +# #endregion TestBuildReportPayload + + +# #region TestPersistReport [C:2] [TYPE Module] +class TestPersistReport: + def test_persist_report(self): + """persist_report delegates to repository.""" + repo = MagicMock() + repo.save_report.return_value = "saved_report" + from src.services.clean_release.report_builder import ComplianceReportBuilder + builder = ComplianceReportBuilder(repo) + mock_report = MagicMock() + result = builder.persist_report(mock_report) + assert result == "saved_report" + repo.save_report.assert_called_once_with(mock_report) + + +# #endregion TestPersistReport + +# #endregion Test.CleanRelease.ReportBuilder diff --git a/backend/tests/services/clean_release/test_stages.py b/backend/tests/services/clean_release/test_stages.py index 694aa2a9..536ec001 100644 --- a/backend/tests/services/clean_release/test_stages.py +++ b/backend/tests/services/clean_release/test_stages.py @@ -177,6 +177,32 @@ class TestStageResultMap: assert result == {} # #endregion test_map_missing_stage_name + # #region test_map_unknown_decision_falls_through [C:2] [TYPE Function] + def test_map_unknown_decision_falls_through(self): + """Custom decision that is truthy but not recognized → mapped as string (line 85). + Uses a valid CheckStageStatus value (PASS/FAIL/SKIPPED/RUNNING) for the enum.""" + run = MagicMock(spec=ComplianceStageRun) + run.stage_name = "DATA_PURITY" + # "FAIL" is truthy and not one of ComplianceDecision values (PASSED/BLOCKED/ERROR) + run.decision = "PASS" # truthy, valid CheckStageStatus value + run.status = "irrelevant_status" + result = stage_result_map([run]) + assert ComplianceStageName.DATA_PURITY in result + assert result[ComplianceStageName.DATA_PURITY] == CheckStageStatus.PASS + # #endregion test_map_unknown_decision_falls_through + + # #region test_map_no_decision_uses_status [C:2] [TYPE Function] + def test_map_no_decision_uses_status(self): + """No decision, only status available → mapped from status (line 87).""" + run = MagicMock(spec=ComplianceStageRun) + run.stage_name = "DATA_PURITY" + run.decision = None # falsy + run.status = "FAIL" # truthy, valid CheckStageStatus value + result = stage_result_map([run]) + assert ComplianceStageName.DATA_PURITY in result + assert result[ComplianceStageName.DATA_PURITY] == CheckStageStatus.FAIL + # #endregion test_map_no_decision_uses_status + # === missing_mandatory_stages === diff --git a/backend/tests/services/dataset_review/test_helpers_edge.py b/backend/tests/services/dataset_review/test_helpers_edge.py new file mode 100644 index 00000000..febe1bfb --- /dev/null +++ b/backend/tests/services/dataset_review/test_helpers_edge.py @@ -0,0 +1,169 @@ +# #region Test.DatasetReview.HelpersEdge [C:2] [TYPE Module] [SEMANTICS test,dataset,helpers,execution,snapshot,edge] +# @BRIEF Edge-case coverage for orchestrator_pkg/_helpers.py uncovered lines. + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from unittest.mock import MagicMock, patch +import pytest + + +class TestBuildExecutionSnapshotEdge: + """Edge cases for build_execution_snapshot.""" + + def test_mapping_missing_template_variable(self): + """Execution mapping with no matching template variable adds blocker (lines 172-173).""" + from src.services.dataset_review.orchestrator_pkg._helpers import build_execution_snapshot + from src.models.dataset_review import ApprovalState + + session = MagicMock() + session.imported_filters = [] + session.execution_mappings = [] + session.template_variables = [] + session.semantic_fields = [] + session.dataset_id = 42 + + # Need a mapping that references a template variable that doesn't exist + mapping = MagicMock() + mapping.filter_id = "fl-1" + mapping.variable_id = "tv-nonexistent" + mapping.mapping_id = "m-1" + mapping.effective_value = "SomeValue" + mapping.approval_state = ApprovalState.PENDING + mapping.requires_explicit_approval = False + mapping.raw_input_value = None + + imported_filter = MagicMock() + imported_filter.filter_id = "fl-1" + imported_filter.filter_name = "region" + imported_filter.normalized_value = "US" + imported_filter.raw_value = "us" + + session.imported_filters = [imported_filter] + session.execution_mappings = [mapping] + session.template_variables = [] # no matching variable + + snapshot = build_execution_snapshot(session) + assert "m-1:missing_variable" in str(snapshot["preview_blockers"]) + + def test_effective_value_falls_back_to_template_default(self): + """When effective_value is None, fall back to template default (line 181).""" + from src.services.dataset_review.orchestrator_pkg._helpers import build_execution_snapshot + from src.models.dataset_review import ApprovalState + + session = MagicMock() + session.dataset_id = 42 + + mapping = MagicMock() + mapping.filter_id = "fl-1" + mapping.variable_id = "tv-1" + mapping.mapping_id = "m-1" + mapping.effective_value = None + mapping.approval_state = ApprovalState.PENDING + mapping.requires_explicit_approval = False + mapping.raw_input_value = None + + imported_filter = MagicMock() + imported_filter.filter_id = "fl-1" + imported_filter.filter_name = "region" + imported_filter.normalized_value = None + imported_filter.raw_value = None + + template_variable = MagicMock() + template_variable.variable_id = "tv-1" + template_variable.variable_name = "region" + template_variable.default_value = "default_region" + template_variable.is_required = True + + session.imported_filters = [imported_filter] + session.execution_mappings = [mapping] + session.template_variables = [template_variable] + session.semantic_fields = [] + + snapshot = build_execution_snapshot(session) + assert snapshot["template_params"].get("region") == "default_region" + + def test_missing_required_value_blocker(self): + """Required template variable without effective value adds blocker (lines 184-187).""" + from src.services.dataset_review.orchestrator_pkg._helpers import build_execution_snapshot + from src.models.dataset_review import ApprovalState + + session = MagicMock() + session.dataset_id = 42 + + mapping = MagicMock() + mapping.filter_id = "fl-1" + mapping.variable_id = "tv-1" + mapping.mapping_id = "m-1" + mapping.effective_value = None + mapping.approval_state = ApprovalState.PENDING + mapping.requires_explicit_approval = False + mapping.raw_input_value = None + + imported_filter = MagicMock() + imported_filter.filter_id = "fl-1" + imported_filter.filter_name = "region" + imported_filter.normalized_value = None + imported_filter.raw_value = None + + template_variable = MagicMock() + template_variable.variable_id = "tv-1" + template_variable.variable_name = "region" + template_variable.default_value = None + template_variable.is_required = True # required but no value + + session.imported_filters = [imported_filter] + session.execution_mappings = [mapping] + session.template_variables = [template_variable] + session.semantic_fields = [] + + snapshot = build_execution_snapshot(session) + blockers = snapshot["preview_blockers"] + assert any("missing_required_value" in str(b) for b in blockers) + + def test_unmapped_filter_without_effective_value_skipped(self): + """Unmapped filter without effective value is skipped (line 222).""" + from src.services.dataset_review.orchestrator_pkg._helpers import build_execution_snapshot + + session = MagicMock() + session.dataset_id = 42 + + # Filter with no effective value and no mapping + imported_filter = MagicMock() + imported_filter.filter_id = "fl-1" + imported_filter.filter_name = "region" + imported_filter.normalized_value = None + imported_filter.raw_value = None + + session.imported_filters = [imported_filter] + session.execution_mappings = [] + session.template_variables = [] + session.semantic_fields = [] + + snapshot = build_execution_snapshot(session) + assert len(snapshot["effective_filters"]) == 0 + + def test_unmapped_required_variable_blocker(self): + """Unmapped required template variable adds blocker (lines 241-242).""" + from src.services.dataset_review.orchestrator_pkg._helpers import build_execution_snapshot + + session = MagicMock() + session.dataset_id = 42 + + template_variable = MagicMock() + template_variable.variable_id = "tv-1" + template_variable.variable_name = "region" + template_variable.default_value = None + template_variable.is_required = True + + session.imported_filters = [] + session.execution_mappings = [] + session.template_variables = [template_variable] + session.semantic_fields = [] + + snapshot = build_execution_snapshot(session) + blockers = snapshot["preview_blockers"] + assert any("unmapped" in str(b) for b in blockers) +# #endregion Test.DatasetReview.HelpersEdge diff --git a/backend/tests/services/dataset_review/test_orchestrator_edge.py b/backend/tests/services/dataset_review/test_orchestrator_edge.py new file mode 100644 index 00000000..401d6027 --- /dev/null +++ b/backend/tests/services/dataset_review/test_orchestrator_edge.py @@ -0,0 +1,444 @@ +# #region Test.DatasetReview.OrchestratorEdge [C:2] [TYPE Module] [SEMANTICS test,dataset,orchestrator,edge,recovery,preview] +# @BRIEF Edge-case coverage for orchestrator.py uncovered paths. +# @RELATION BINDS_TO -> [DatasetReviewOrchestrator] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from unittest.mock import MagicMock, AsyncMock, patch +import pytest + + +@pytest.fixture +def mock_repository(): + repo = MagicMock() + repo.db = MagicMock() + repo.event_logger = MagicMock() + repo.bump_session_version = MagicMock(return_value=2) + return repo + + +@pytest.fixture +def mock_config_manager(): + cm = MagicMock() + env = MagicMock() + env.id = "env-1" + env.name = "Test Environment" + cm.get_environment.return_value = env + return cm + + +@pytest.fixture +def mock_user(): + user = MagicMock() + user.id = "user-1" + return user + + +@pytest.fixture +def orchestrator(mock_repository, mock_config_manager): + from src.services.dataset_review.orchestrator import DatasetReviewOrchestrator + return DatasetReviewOrchestrator( + repository=mock_repository, + config_manager=mock_config_manager, + ) + + +class TestStartSessionEdge: + """Edge cases for start_session.""" + + @pytest.mark.asyncio + async def test_start_session_superset_link_no_partial_recovery( + self, orchestrator, mock_repository, mock_config_manager, mock_user + ): + """Superset link without partial_recovery (line 170).""" + from src.services.dataset_review.orchestrator_pkg._commands import StartSessionCommand + + parsed_context = MagicMock() + parsed_context.partial_recovery = False + parsed_context.dataset_ref = "schema.table" + parsed_context.dataset_id = 42 + parsed_context.dashboard_id = 99 + parsed_context.dataset_payload = {"id": 42} + + command = StartSessionCommand( + source_kind="superset_link", + source_input="http://superset.example.com/dashboard/99", + environment_id="env-1", + user=mock_user, + ) + + persisted_session = MagicMock() + persisted_session.session_id = "sess-3" + persisted_session.current_phase = MagicMock(value="review") + persisted_session.readiness_state = MagicMock(value="review_ready") + persisted_session.source_kind = "superset_link" + persisted_session.dataset_ref = "schema.table" + persisted_session.dataset_id = 42 + persisted_session.dashboard_id = 99 + persisted_session.active_task_id = None + persisted_session.user_id = "user-1" + persisted_session.environment_id = "env-1" + persisted_session.source_input = "test" + mock_repository.create_session.return_value = persisted_session + mock_repository.save_profile_and_findings.return_value = persisted_session + + with patch("src.services.dataset_review.orchestrator.SupersetContextExtractor") as mock_extractor_cls, \ + patch("src.services.dataset_review.orchestrator.build_initial_profile") as mock_build_profile, \ + patch("src.services.dataset_review.orchestrator.build_partial_recovery_findings"): + + mock_extractor = MagicMock() + mock_extractor.parse_superset_link = AsyncMock(return_value=parsed_context) + mock_extractor.recover_imported_filters.return_value = [] + mock_extractor.discover_template_variables.return_value = [] + mock_extractor_cls.return_value = mock_extractor + + mock_profile = MagicMock() + mock_build_profile.return_value = mock_profile + mock_repository.save_recovery_state.return_value = persisted_session + + result = await orchestrator.start_session(command) + assert result is not None + assert result.session.session_id == "sess-3" + + @pytest.mark.asyncio + async def test_start_session_with_recovery_state( + self, orchestrator, mock_repository, mock_user + ): + """start_session with recovered filters saves recovery state (line 233).""" + from src.services.dataset_review.orchestrator_pkg._commands import StartSessionCommand + + command = StartSessionCommand( + source_kind="dataset_selection", + source_input="42", + environment_id="env-1", + user=mock_user, + ) + + persisted_session = MagicMock() + persisted_session.session_id = "sess-4" + persisted_session.current_phase = MagicMock(value="review") + persisted_session.readiness_state = MagicMock(value="review_ready") + persisted_session.source_kind = "dataset_selection" + persisted_session.dataset_ref = "dataset:42" + persisted_session.dataset_id = 42 + persisted_session.dashboard_id = None + persisted_session.active_task_id = None + persisted_session.user_id = "user-1" + persisted_session.environment_id = "env-1" + persisted_session.source_input = "42" + mock_repository.create_session.return_value = persisted_session + mock_repository.save_profile_and_findings.return_value = persisted_session + mock_repository.save_recovery_state.return_value = persisted_session + + with patch("src.services.dataset_review.orchestrator.build_initial_profile") as mock_build_profile, \ + patch("src.services.dataset_review.orchestrator.parse_dataset_selection", + return_value=("dataset:42", 42)): + mock_profile = MagicMock() + mock_build_profile.return_value = mock_profile + + result = await orchestrator.start_session(command) + assert result is not None + + +class TestPrepareLaunchPreviewEdge: + """Edge cases for prepare_launch_preview.""" + + def test_prepare_preview_blocked_by_execution( + self, orchestrator, mock_repository + ): + """Preview blocked by execution snapshot blockers (lines 300-301).""" + from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand + + session = MagicMock() + session.session_id = "sess-1" + session.user_id = "user-1" + session.dataset_id = 42 + session.environment_id = "env-1" + session.current_phase = MagicMock(value="review") + session.last_activity_at = None + session.version = 1 + mock_repository.load_session_detail.return_value = session + + with patch("src.services.dataset_review.orchestrator.build_execution_snapshot") as mock_build: + mock_build.return_value = { + "preview_blockers": ["missing_filter"], + "preview_fingerprint": "", + "template_params": {}, + "effective_filters": [], + } + + command = PreparePreviewCommand( + session_id="sess-1", + expected_version=1, + user=MagicMock(id="user-1"), + ) + + with pytest.raises(ValueError, match="Preview blocked"): + orchestrator.prepare_launch_preview(command) + + def test_prepare_preview_partially_ready( + self, orchestrator, mock_repository, mock_config_manager + ): + """Preview with non-READY status sets PARTIALLY_READY (lines 331-332).""" + from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand + from src.services.dataset_review.orchestrator_pkg._helpers import PreviewStatus + + session = MagicMock() + session.session_id = "sess-2" + session.user_id = "user-1" + session.dataset_id = 42 + session.environment_id = "env-1" + session.current_phase = MagicMock(value="review") + session.last_activity_at = None + session.version = 1 + mock_repository.load_session_detail.return_value = session + + with patch("src.services.dataset_review.orchestrator.build_execution_snapshot") as mock_build_snapshot, \ + patch("src.services.dataset_review.orchestrator.SupersetCompilationAdapter") as mock_adapter_cls, \ + patch("src.services.dataset_review.orchestrator.build_launch_blockers") as mock_build_blockers: + + mock_build_snapshot.return_value = { + "preview_blockers": [], + "preview_fingerprint": "fp-1", + "template_params": {"region": "US"}, + "effective_filters": [], + } + + mock_adapter = MagicMock() + mock_adapter_cls.return_value = mock_adapter + preview = MagicMock() + preview.preview_id = "pv-1" + preview.preview_status = PreviewStatus.PENDING + preview.preview_fingerprint = "fp-1" + mock_adapter.compile_preview.return_value = preview + + persisted_preview = MagicMock() + persisted_preview.preview_id = "pv-1" + persisted_preview.preview_status = PreviewStatus.PENDING + mock_repository.save_preview.return_value = persisted_preview + + mock_build_blockers.return_value = [] + + command = PreparePreviewCommand( + session_id="sess-2", + expected_version=1, + user=MagicMock(id="user-1"), + ) + + result = orchestrator.prepare_launch_preview(command) + assert result is not None + # Check session readiness state was updated to PARTIALLY_READY + assert session.readiness_state.value == "partially_ready" + + def test_prepare_preview_no_dataset_id( + self, orchestrator, mock_repository + ): + """Missing dataset_id raises ValueError (line 371).""" + from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand + + session = MagicMock() + session.session_id = "sess-1" + session.user_id = "user-1" + session.dataset_id = None + session.environment_id = "env-1" + mock_repository.load_session_detail.return_value = session + + command = PreparePreviewCommand( + session_id="sess-1", + expected_version=1, + user=MagicMock(id="user-1"), + ) + + with pytest.raises(ValueError, match="requires a resolved dataset_id"): + orchestrator.prepare_launch_preview(command) + + def test_prepare_preview_env_not_found( + self, orchestrator, mock_repository, mock_config_manager + ): + """Missing environment raises ValueError (line 375).""" + from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand + + mock_config_manager.get_environment.return_value = None + + session = MagicMock() + session.session_id = "sess-1" + session.user_id = "user-1" + session.dataset_id = 42 + session.environment_id = "nonexistent" + session.current_phase = MagicMock(value="review") + mock_repository.load_session_detail.return_value = session + + command = PreparePreviewCommand( + session_id="sess-1", + expected_version=1, + user=MagicMock(id="user-1"), + ) + + with pytest.raises(ValueError, match="Environment not found"): + orchestrator.prepare_launch_preview(command) + + def test_prepare_preview_compiled_ready_with_blockers( + self, orchestrator, mock_repository, mock_config_manager + ): + """Preview READY with launch blockers sets COMPILED_PREVIEW_READY (lines 325-326).""" + from src.services.dataset_review.orchestrator_pkg._commands import PreparePreviewCommand + from src.services.dataset_review.orchestrator_pkg._helpers import PreviewStatus + + session = MagicMock() + session.session_id = "sess-3" + session.user_id = "user-1" + session.dataset_id = 42 + session.environment_id = "env-1" + session.current_phase = MagicMock(value="review") + session.last_activity_at = None + session.version = 1 + mock_repository.load_session_detail.return_value = session + + with patch("src.services.dataset_review.orchestrator.build_execution_snapshot") as mock_build_snapshot, \ + patch("src.services.dataset_review.orchestrator.SupersetCompilationAdapter") as mock_adapter_cls, \ + patch("src.services.dataset_review.orchestrator.build_launch_blockers") as mock_build_blockers: + + mock_build_snapshot.return_value = { + "preview_blockers": [], + "preview_fingerprint": "fp-1", + "template_params": {"region": "US"}, + "effective_filters": [], + } + + mock_adapter = MagicMock() + mock_adapter_cls.return_value = mock_adapter + preview = MagicMock() + preview.preview_status = PreviewStatus.READY + mock_adapter.compile_preview.return_value = preview + + persisted_preview = MagicMock() + persisted_preview.preview_status = PreviewStatus.READY + mock_repository.save_preview.return_value = persisted_preview + + mock_build_blockers.return_value = ["finding blocking"] + + command = PreparePreviewCommand( + session_id="sess-3", + expected_version=1, + user=MagicMock(id="user-1"), + ) + + result = orchestrator.prepare_launch_preview(command) + assert result is not None + + +class TestBuildRecoveryBootstrapEdge: + """Edge cases for _build_recovery_bootstrap.""" + + @pytest.mark.asyncio + async def test_recovery_without_imported_filters_payload( + self, orchestrator, mock_config_manager + ): + """recover_imported_filters returns None (line 467).""" + session = MagicMock() + session.session_id = "sess-1" + session.dataset_id = 42 + + parsed_context = MagicMock() + parsed_context.dataset_payload = {"id": 42} + parsed_context.unresolved_references = [] + + with patch("src.services.dataset_review.orchestrator.SupersetContextExtractor") as mock_extractor_cls: + mock_extractor = MagicMock() + mock_extractor.recover_imported_filters.return_value = None + mock_extractor.discover_template_variables.return_value = [] + mock_extractor_cls.return_value = mock_extractor + + filters, vars_, mappings, findings = ( + await orchestrator._build_recovery_bootstrap( + MagicMock(), session, parsed_context, [] + ) + ) + assert filters == [] + assert vars_ == [] + + @pytest.mark.asyncio + async def test_recovery_skip_unmatched_variable( + self, orchestrator, mock_config_manager + ): + """Template variable with no matching filter is skipped (line 527).""" + session = MagicMock() + session.session_id = "sess-1" + session.dataset_id = 42 + + parsed_context = MagicMock() + parsed_context.dataset_payload = {"id": 42} + parsed_context.unresolved_references = [] + + with patch("src.services.dataset_review.orchestrator.SupersetContextExtractor") as mock_extractor_cls: + mock_extractor = MagicMock() + mock_extractor.recover_imported_filters.return_value = [ + {"filter_name": "region"} + ] + mock_extractor.discover_template_variables.return_value = [ + {"variable_name": "unmatched_var"} # no matching filter + ] + mock_extractor_cls.return_value = mock_extractor + + filters, vars_, mappings, findings = ( + await orchestrator._build_recovery_bootstrap( + MagicMock(), session, parsed_context, [] + ) + ) + assert len(mappings) == 0 # No mapping created for unmatched variable + + +class TestLaunchDatasetEdge: + """Edge cases for launch_dataset.""" + + def test_launch_no_dataset_id( + self, orchestrator, mock_repository + ): + """Missing dataset_id in launch (some uncovered branch).""" + from src.services.dataset_review.orchestrator_pkg._commands import LaunchDatasetCommand + + session = MagicMock() + session.session_id = "sess-1" + session.user_id = "user-1" + session.dataset_id = None + session.environment_id = "env-1" + session.current_phase = MagicMock(value="preview") + mock_repository.load_session_detail.return_value = session + + command = LaunchDatasetCommand( + session_id="sess-1", + expected_version=1, + user=MagicMock(id="user-1"), + ) + + with pytest.raises(ValueError, match="dataset_id"): + orchestrator.launch_dataset(command) + + def test_launch_env_not_found( + self, orchestrator, mock_repository, mock_config_manager + ): + """Missing environment in launch.""" + from src.services.dataset_review.orchestrator_pkg._commands import LaunchDatasetCommand + + mock_config_manager.get_environment.return_value = None + + session = MagicMock() + session.session_id = "sess-1" + session.user_id = "user-1" + session.dataset_id = 42 + session.environment_id = "nonexistent" + session.current_phase = MagicMock(value="preview") + mock_repository.load_session_detail.return_value = session + + command = LaunchDatasetCommand( + session_id="sess-1", + expected_version=1, + user=MagicMock(id="user-1"), + ) + + with pytest.raises(ValueError, match="Environment not found"): + orchestrator.launch_dataset(command) +# #endregion Test.DatasetReview.OrchestratorEdge diff --git a/backend/tests/services/dataset_review/test_semantic_resolver.py b/backend/tests/services/dataset_review/test_semantic_resolver.py index c0abb56f..45c414bf 100644 --- a/backend/tests/services/dataset_review/test_semantic_resolver.py +++ b/backend/tests/services/dataset_review/test_semantic_resolver.py @@ -62,13 +62,13 @@ class TestSemanticSourceResolverResolveFromDictionary: source_payload = { "source_ref": "dict_1", "rows": [ - {"column_name": "region_name", "verbose_name": "Region Name", "description": "Region name"}, + {"column_name": "regions", "verbose_name": "Regions", "description": "Geographic regions"}, ], } fields = [{"field_name": "region"}] result = resolver.resolve_from_dictionary(source_payload, fields) assert len(result.resolved_fields) == 1 - # region vs region_name has SequenceMatcher ratio > 0.72 -> fuzzy match + # 'region' vs 'regions' has SequenceMatcher ratio ~0.92 (> 0.72 threshold) -> fuzzy match assert result.resolved_fields[0]["provenance"] == "fuzzy_inferred" assert result.resolved_fields[0]["needs_review"] is True diff --git a/backend/tests/services/dataset_review/test_semantic_resolver_edge.py b/backend/tests/services/dataset_review/test_semantic_resolver_edge.py new file mode 100644 index 00000000..39a42fa3 --- /dev/null +++ b/backend/tests/services/dataset_review/test_semantic_resolver_edge.py @@ -0,0 +1,116 @@ +# #region Test.DatasetReview.SemanticResolverEdge [C:2] [TYPE Module] [SEMANTICS test,semantic,resolver,fuzzy,match,edge] +# @BRIEF Edge-case coverage for semantic_resolver.py uncovered lines (149, 343, 347). + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from unittest.mock import patch +import pytest + + +class TestSemanticSourceResolverEdge: + """Cover fuzzy match and row processing edge cases.""" + + @pytest.fixture + def resolver(self): + from src.services.dataset_review.semantic_resolver import SemanticSourceResolver + return SemanticSourceResolver() + + def test_find_fuzzy_matches_with_empty_key(self, resolver): + """Row with empty field_key is skipped (line 343).""" + field_name = "region" + rows = [ + {"field_key": "", "field_name": "Empty"}, + {"field_key": "region", "field_name": "Region"}, + ] + result = resolver._find_fuzzy_matches(field_name, rows) + assert len(result) >= 1 + # The empty key row should be skipped, only region matches + + def test_find_fuzzy_matches_low_score_skipped(self, resolver): + """Row with score below 0.72 is skipped (line 347).""" + field_name = "region" + rows = [ + {"field_key": "completely_different_name", "field_name": "Different"}, + ] + result = resolver._find_fuzzy_matches(field_name, rows) + assert len(result) == 0 + + def test_find_fuzzy_matches_good_score(self, resolver): + """Row with score above 0.72 is included.""" + # Use strings close enough to exceed 0.72 threshold + field_name = "salesregion" + rows = [ + {"field_key": "sales_region", "field_name": "Sales Region"}, + ] + result = resolver._find_fuzzy_matches(field_name, rows) + assert len(result) >= 1 + assert result[0]["score"] >= 0.72 + + def test_find_fuzzy_matches_top_3(self, resolver): + """Only top 3 fuzzy matches returned.""" + field_name = "sales_amount_value" + rows = [ + {"field_key": f"sales_amount_{i}" if i < 5 else "unrelated", "field_name": f"Sales {i}"} + for i in range(10) + ] + result = resolver._find_fuzzy_matches(field_name, rows) + assert len(result) <= 3 + + def test_resolve_from_dictionary_no_matches(self, resolver): + """Field with no dictionary match produces unresolved entry.""" + source_payload = { + "source_ref": "test_dict", + "rows": [ + {"field_name": "Region"}, + {"field_name": "Country"}, + ], + } + fields = [{"field_name": "nonexistent_field"}] + + result = resolver.resolve_from_dictionary(source_payload, fields) + assert result.unresolved_fields == ["nonexistent_field"] + assert len(result.resolved_fields) == 1 + assert result.resolved_fields[0]["status"] == "unresolved" + + def test_normalize_dictionary_row(self, resolver): + """_normalize_dictionary_row produces expected keys.""" + row = {"field_name": "My Field", "verbose_name": "My Field Label"} + result = resolver._normalize_dictionary_row(row) + assert result["field_key"] == "myfield" # normalized: lowercase, no spaces + assert result["field_name"] == "My Field" + + def test_resolve_from_dictionary_fuzzy_match(self, resolver): + """Fuzzy match produces ranked candidates (coverage for line 149).""" + source_payload = { + "source_ref": "test_dict", + "rows": [ + {"field_name": "Sales Amount"}, + {"field_name": "Revenue"}, + ], + } + # Use a field name close enough to "sales amount" + fields = [{"field_name": "sales_amount"}] + + result = resolver.resolve_from_dictionary(source_payload, fields) + assert len(result.resolved_fields) == 1 + assert result.resolved_fields[0]["field_name"] == "sales_amount" + candidates = result.resolved_fields[0].get("candidates", []) + # Should have at least one candidate (exact or fuzzy) + assert len(candidates) >= 1 + + def test_resolve_from_dictionary_locked_field(self, resolver): + """Locked field is preserved.""" + source_payload = { + "source_ref": "test_dict", + "rows": [ + {"field_name": "Region"}, + ], + } + fields = [{"field_name": "region", "is_locked": True}] + + result = resolver.resolve_from_dictionary(source_payload, fields) + assert result.resolved_fields[0].get("is_locked") is True +# #endregion Test.DatasetReview.SemanticResolverEdge diff --git a/backend/tests/services/test_notification_providers.py b/backend/tests/services/test_notification_providers.py index 24395747..25741776 100644 --- a/backend/tests/services/test_notification_providers.py +++ b/backend/tests/services/test_notification_providers.py @@ -233,10 +233,14 @@ class TestGetHttpClient: await client.aclose() providers_mod._http_client = None - def test_abstract_send_has_pass(self): + @pytest.mark.asyncio + async def test_abstract_send_has_pass(self): """Cover abstract method pass at line 72.""" from src.services.notifications.providers import NotificationProvider - # Instantiate directly (no ABC enforcement in Python without meta) - # The `pass` body is a no-op - pass + # Patch __abstractmethods__ to bypass ABC instantiation check + import unittest.mock as mock + with mock.patch.object(NotificationProvider, '__abstractmethods__', set()): + p = NotificationProvider() + result = await p.send("target", "subject", "body", {}) + assert result is None # pass returns None # #endregion Test.NotificationProviders diff --git a/backend/tests/services/test_report_service.py b/backend/tests/services/test_report_service.py index 868cc7b4..4bc99788 100644 --- a/backend/tests/services/test_report_service.py +++ b/backend/tests/services/test_report_service.py @@ -390,5 +390,58 @@ class TestGetReportDetail: result = service.get_report_detail("task-1") assert result is not None assert result.diagnostics is not None + + def test_diagnostics_empty_details_uses_fallback(self): + """When details is empty/falsy, uses 'Not provided' fallback (line 247).""" + from src.models.report import TaskReport, ReportStatus, TaskType + from unittest.mock import PropertyMock + from datetime import UTC, datetime + + tm = MagicMock() + tm.get_all_tasks.return_value = [] + service = _make_service(task_manager=tm) + + # Mock _load_normalized_reports to return a report with empty details + mock_report = MagicMock(spec=TaskReport) + mock_report.report_id = "empty-details-task" + mock_report.task_type = TaskType.MIGRATION + mock_report.status = ReportStatus.SUCCESS + mock_report.updated_at = datetime(2024, 1, 1, tzinfo=UTC) + mock_report.started_at = None + mock_report.error_context = None + type(mock_report).details = PropertyMock(return_value={}) + + with patch.object(service, "_load_normalized_reports", return_value=[mock_report]): + result = service.get_report_detail("empty-details-task") + assert result is not None + assert result.diagnostics == {"note": "Not provided"} + + def test_default_next_actions_for_failed_without_error_context_actions(self): + """When error_context exists but next_actions is empty, uses default (line 290).""" + from src.models.report import TaskReport, ReportStatus, TaskType, ErrorContext + from datetime import UTC, datetime + + tm = MagicMock() + tm.get_all_tasks.return_value = [] + service = _make_service(task_manager=tm) + + mock_report = MagicMock(spec=TaskReport) + mock_report.report_id = "failed-no-actions" + mock_report.task_type = TaskType.MIGRATION + mock_report.status = ReportStatus.FAILED + mock_report.updated_at = datetime(2024, 1, 1, tzinfo=UTC) + mock_report.started_at = None + mock_report.details = {"result": {}} + mock_report.error_context = ErrorContext( + message="test error", + code=None, + next_actions=[], + ) + + with patch.object(service, "_load_normalized_reports", return_value=[mock_report]): + result = service.get_report_detail("failed-no-actions") + assert result is not None + assert "Review diagnostics" in result.next_actions + assert "Retry task if applicable" in result.next_actions # #endregion test_get_report_detail # #endregion Test.ReportsService diff --git a/backend/tests/services/test_services___init__.py b/backend/tests/services/test_services___init__.py new file mode 100644 index 00000000..a929401e --- /dev/null +++ b/backend/tests/services/test_services___init__.py @@ -0,0 +1,29 @@ +# #region Test.ServicesInit [C:2] [TYPE Module] [SEMANTICS test,services,init,lazy-loading] +# @BRIEF Tests for services/__init__.py — lazy-loading __getattr__. +# @RELATION BINDS_TO -> [services] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import pytest + + +class TestServicesInit: + def test_getattr_mapping_service(self): + import src.services as svc + ms = svc.MappingService + from src.services.mapping_service import MappingService + assert ms is MappingService + + def test_getattr_resource_service(self): + import src.services as svc + rs = svc.ResourceService + from src.services.resource_service import ResourceService + assert rs is ResourceService + + def test_getattr_raises_for_unknown(self): + import src.services as svc + with pytest.raises(AttributeError, match="has no attribute"): + svc.__getattr__("NonExistentService") diff --git a/backend/tests/test_agent/test_app.py b/backend/tests/test_agent/test_app.py index cc582744..178700a0 100644 --- a/backend/tests/test_agent/test_app.py +++ b/backend/tests/test_agent/test_app.py @@ -351,4 +351,53 @@ class TestHealth: result = await health() assert result["status"] == "ok" # #endregion test_health + + +# #region test_file_upload_parsing [C:2] [TYPE Function] +# @BRIEF Test file upload branch — parse_upload called for valid small files. +class TestFileUploadParsing: + @pytest.mark.asyncio + async def test_handler_parses_uploaded_file(self, mock_request, tmp_path): + """Valid small file triggers parse_upload and continues to stream.""" + from src.agent.app import agent_handler + test_file = tmp_path / "test.txt" + test_file.write_text("upload content for analysis") + + message = {"text": "analyze", "files": [str(test_file)]} + + with patch('src.agent.app.create_agent') as mock_create, \ + patch('src.agent.app.get_all_tools', return_value=[]), \ + patch('src.agent.app._save_conversation', AsyncMock()), \ + patch('src.agent.app.log_tool_event', AsyncMock()): + agent = _make_agent_mock([ + {"event": "on_chat_model_stream", "data": {"chunk": MagicMock(content="result")}} + ]) + mock_create.return_value = agent + results = [r async for r in agent_handler(message, [], mock_request, None, None)] + assert len(results) > 0 + data = json.loads(results[0]) + assert data["metadata"]["type"] == "stream_token" +# #endregion test_file_upload_parsing + + +# #region test_app_main_block [C:2] [TYPE Function] +# @BRIEF Test if __name__ == '__main__' block in app.py. +class TestAppMainBlock: + def test_app_main_block(self): + """if __name__ == '__main__' creates ChatInterface and launches.""" + import importlib.util + from pathlib import Path + + app_path = Path(__file__).parent.parent.parent / "src" / "agent" / "app.py" + spec = importlib.util.spec_from_file_location("__main__", str(app_path)) + + mock_demo = MagicMock() + with patch('gradio.ChatInterface') as mock_ci: + mock_ci.return_value = mock_demo + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + mock_demo.launch.assert_called_once() +# #endregion test_app_main_block # #endregion Test.AgentChat.GradioApp diff --git a/backend/tests/test_agent/test_document_parser.py b/backend/tests/test_agent/test_document_parser.py index 14b7b9e9..ccd80263 100644 --- a/backend/tests/test_agent/test_document_parser.py +++ b/backend/tests/test_agent/test_document_parser.py @@ -8,6 +8,8 @@ sys.path.append(str(Path(__file__).parent.parent.parent / "src")) import os import tempfile +from unittest.mock import MagicMock, patch + import pytest from src.agent.document_parser import parse_pdf, parse_xlsx, parse_upload, ParseError @@ -188,5 +190,114 @@ def test_parse_upload_dict(): assert "Dict test" in result finally: os.unlink(tmp_path) + + +def test_parse_upload_file_path_fallback(): + """parse_upload falls back to file_path key if path is missing.""" + with tempfile.NamedTemporaryFile(suffix=".txt", mode="w", delete=False) as f: + f.write("Fallback key test") + tmp_path = f.name + + try: + result = parse_upload({"name": "test.txt", "file_path": tmp_path}) + assert "Fallback" in result + finally: + os.unlink(tmp_path) # #endregion TestAgentChat.DocumentParser.ParseUpload + + +# #region TestAgentChat.DocumentParser.ImportErrors [C:2] [TYPE Function] [SEMANTICS test,parser,import,error] +# @BRIEF Test ImportError paths in parse_pdf and parse_xlsx. + +def test_parse_pdf_import_error(): + """pdfplumber import fails → ParseError.""" + import builtins + real_import = builtins.__import__ + + def raise_on_pdfplumber(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'pdfplumber': + raise ImportError(f"No module named pdfplumber", name=name) + return real_import(name, globals, locals, fromlist, level) + + with patch('builtins.__import__', side_effect=raise_on_pdfplumber): + with pytest.raises(ParseError, match="pdfplumber not installed"): + parse_pdf("/tmp/dummy.pdf") + + +def test_parse_xlsx_import_error(): + """openpyxl import fails → ParseError.""" + import builtins + real_import = builtins.__import__ + + def raise_on_openpyxl(name, globals=None, locals=None, fromlist=(), level=0): + if name == 'openpyxl': + raise ImportError(f"No module named openpyxl", name=name) + return real_import(name, globals, locals, fromlist, level) + + with patch('builtins.__import__', side_effect=raise_on_openpyxl): + with pytest.raises(ParseError, match="openpyxl not installed"): + parse_xlsx("/tmp/dummy.xlsx") +# #endregion TestAgentChat.DocumentParser.ImportErrors + + +# #region TestAgentChat.DocumentParser.PyPDF2Fallback [C:2] [TYPE Function] [SEMANTICS test,parser,pypdf2,fallback] +# @BRIEF When pdfplumber fails, PyPDF2 is used as fallback path. + +def test_parse_pdf_pypdf2_fallback(): + """pdfplumber failure triggers PyPDF2 fallback.""" + # Build mock PyPDF2 module in sys.modules + mock_pypdf2 = MagicMock() + mock_reader = MagicMock() + mock_page = MagicMock() + mock_page.extract_text.return_value = "Fallback PyPDF2 text" + mock_reader.pages = [mock_page] + mock_pypdf2.PdfReader = MagicMock(return_value=mock_reader) + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: + f.write(b"dummy pdf content") + tmp_path = f.name + + try: + # Patch pdfplumber.open directly (the real module) — NOT document_parser.pdfplumber + # because pdfplumber is imported inside the function body, not at module level. + with patch('pdfplumber.open', side_effect=Exception("pdfplumber error")), \ + patch.dict('sys.modules', {'PyPDF2': mock_pypdf2}): + result = parse_pdf(tmp_path) + assert isinstance(result, str) + assert "PyPDF2" in result + mock_pypdf2.PdfReader.assert_called_once() + finally: + os.unlink(tmp_path) +# #endregion TestAgentChat.DocumentParser.PyPDF2Fallback + + +# #region TestAgentChat.DocumentParser.ParseUploadBranches [C:2] [TYPE Function] [SEMANTICS test,parser,upload,branches] +# @BRIEF Test parse_upload dispatches to correct parser for PDF, XLSX, XLS. + +def test_parse_upload_pdf_branch(): + """parse_upload with .pdf dispatches to parse_pdf.""" + import src.agent.document_parser as dp + with patch.object(dp, 'parse_pdf', return_value="pdf result") as mock_parse: + result = parse_upload({"name": "report.pdf", "path": "/tmp/report.pdf"}) + assert result == "pdf result" + mock_parse.assert_called_once_with("/tmp/report.pdf") + + +def test_parse_upload_xlsx_branch(): + """parse_upload with .xlsx dispatches to parse_xlsx.""" + import src.agent.document_parser as dp + with patch.object(dp, 'parse_xlsx', return_value="xlsx result") as mock_parse: + result = parse_upload({"name": "data.xlsx", "path": "/tmp/data.xlsx"}) + assert result == "xlsx result" + mock_parse.assert_called_once_with("/tmp/data.xlsx") + + +def test_parse_upload_xls_branch(): + """parse_upload with .xls (legacy) also dispatches to parse_xlsx.""" + import src.agent.document_parser as dp + with patch.object(dp, 'parse_xlsx', return_value="xls result") as mock_parse: + result = parse_upload({"name": "legacy.xls", "path": "/tmp/legacy.xls"}) + assert result == "xls result" + mock_parse.assert_called_once_with("/tmp/legacy.xls") +# #endregion TestAgentChat.DocumentParser.ParseUploadBranches # #endregion TestAgentChat.DocumentParser diff --git a/backend/tests/test_agent/test_run.py b/backend/tests/test_agent/test_run.py index 99060516..400bf61b 100644 --- a/backend/tests/test_agent/test_run.py +++ b/backend/tests/test_agent/test_run.py @@ -125,4 +125,106 @@ class TestFetchLlmConfig: # #endregion test_fetch_llm_config + + +# #region test_main_block [C:2] [TYPE Function] +# @BRIEF Test if __name__ == '__main__' block — service JWT, LLM config, port fallback, OSError. +class TestMainBlock: + """Test the if __name__ == '__main__' entry point block via importlib.util fresh module.""" + + def _run_as_main(self, monkeypatch, env_overrides=None, llm_configured=False, + port_bind_sequence=None, port_always_fail=False): + """Execute run.py as __main__ with given mocking configuration.""" + import importlib.util + from pathlib import Path + + run_path = Path(__file__).parent.parent.parent / "src" / "agent" / "run.py" + spec = importlib.util.spec_from_file_location("__main__", str(run_path)) + + # Apply env overrides + env_overrides = env_overrides or {} + for k, v in env_overrides.items(): + monkeypatch.setenv(k, v) + + with patch('httpx.get') as mock_httpx_get, \ + patch('socket.socket') as mock_socket_cls, \ + patch('src.agent.app.create_chat_interface') as mock_create_ci, \ + patch('src.agent.context.set_service_jwt') as mock_set_jwt, \ + patch('src.agent.langgraph_setup.configure_from_api') as mock_configure: + # httpx for _fetch_llm_config + mock_resp = MagicMock() + if llm_configured: + mock_resp.json.return_value = { + "configured": True, "provider_type": "openai", + "default_model": "gpt-4o", "api_key": "sk-test", + } + else: + mock_resp.json.return_value = {"configured": False} + mock_httpx_get.return_value = mock_resp + + # socket for _find_free_port + mock_sock = MagicMock() + mock_sock.__enter__.return_value = mock_sock + mock_socket_cls.return_value = mock_sock + if port_always_fail: + mock_sock.bind.side_effect = OSError("all ports busy") + elif port_bind_sequence is not None: + mock_sock.bind.side_effect = port_bind_sequence + else: + mock_sock.bind.side_effect = [None] # first bind succeeds + + mock_demo = MagicMock() + mock_create_ci.return_value = mock_demo + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + return { + 'set_jwt': mock_set_jwt, + 'configure': mock_configure, + 'demo': mock_demo, + } + + def test_main_block_basic(self, monkeypatch): + """Main block with default env, no SERVICE_JWT, no LLM config.""" + # Ensure SERVICE_JWT is NOT set (some tests leak it via os.environ) + monkeypatch.delenv("SERVICE_JWT", raising=False) + result = self._run_as_main(monkeypatch, + env_overrides={"GRADIO_SERVER_PORT": "27860"}) + result['set_jwt'].assert_not_called() + result['configure'].assert_not_called() + result['demo'].launch.assert_called_once() + + def test_main_block_with_service_jwt(self, monkeypatch): + """Main block sets service JWT via ContextVar.""" + result = self._run_as_main(monkeypatch, env_overrides={ + "SERVICE_JWT": "test-service-token", + "GRADIO_SERVER_PORT": "27861", + }) + result['set_jwt'].assert_called_once_with("test-service-token") + + def test_main_block_with_llm_config(self, monkeypatch): + """Main block calls configure_from_api when LLM config is active.""" + result = self._run_as_main(monkeypatch, + env_overrides={"GRADIO_SERVER_PORT": "27862"}, + llm_configured=True) + result['configure'].assert_called_once() + + def test_main_block_port_fallback(self, monkeypatch): + """Port in use triggers fallback warning (logged but continues).""" + # Ports 27863, 27864 busy → 27865 free + with patch('src.agent.run.logger') as mock_logger: + result = self._run_as_main(monkeypatch, + env_overrides={"GRADIO_SERVER_PORT": "27863"}, + port_bind_sequence=[OSError("in use"), OSError("in use"), None]) + result['demo'].launch.assert_called_once() + + def test_main_block_port_oserror(self, monkeypatch): + """OSError during port finding raises in main block.""" + with patch('src.agent.run.logger') as mock_logger: + with pytest.raises(OSError): + self._run_as_main(monkeypatch, + env_overrides={"GRADIO_SERVER_PORT": "27866"}, + port_always_fail=True) +# #endregion test_main_block # #endregion Test.AgentChat.Run diff --git a/backend/tests/test_core/test_archive_parser.py b/backend/tests/test_core/test_archive_parser.py new file mode 100644 index 00000000..a84ec491 --- /dev/null +++ b/backend/tests/test_core/test_archive_parser.py @@ -0,0 +1,232 @@ +# #region Test.MigrationArchiveParser [C:3] [TYPE Module] [SEMANTICS test,migration,archive,parser] +# @BRIEF Tests for core/migration/archive_parser.py — MigrationArchiveParser. +# @RELATION BINDS_TO -> [MigrationArchiveParserModule] +# @TEST_EDGE: invalid_yaml_skipped -> logger.reflect called +# @TEST_EDGE: non_dict_payload -> returns None + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import json +from pathlib import Path as PathLib +import tempfile +import zipfile +from unittest.mock import MagicMock, patch +import pytest +import yaml + + +class TestExtractObjectsFromZip: + """Verify extract_objects_from_zip.""" + + def test_happy_path_extracts_all_types(self): + from src.core.migration.archive_parser import MigrationArchiveParser + + parser = MigrationArchiveParser() + + # Create a temp ZIP with YAML files + with tempfile.TemporaryDirectory() as tmpdir: + tmp = PathLib(tmpdir) + + # Create dashboard YAML + dash_dir = tmp / "dashboards" + dash_dir.mkdir(parents=True) + with open(dash_dir / "dash1.yaml", "w") as f: + yaml.dump({"uuid": "dash-uuid-1", "dashboard_title": "Sales Dashboard"}, f) + + # Create chart YAML + chart_dir = tmp / "charts" + chart_dir.mkdir(parents=True) + with open(chart_dir / "chart1.yaml", "w") as f: + yaml.dump({"uuid": "chart-uuid-1", "slice_name": "Revenue Chart", "viz_type": "bar"}, f) + + # Create dataset YAML + ds_dir = tmp / "datasets" + ds_dir.mkdir(parents=True) + with open(ds_dir / "ds1.yaml", "w") as f: + yaml.dump({"uuid": "ds-uuid-1", "table_name": "orders", "schema": "public"}, f) + + # Create ZIP + zip_path = tmp / "archive.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for file_path in tmp.rglob("*"): + if file_path.is_file() and file_path.suffix != ".zip": + zf.write(file_path, file_path.relative_to(tmp)) + + result = parser.extract_objects_from_zip(str(zip_path)) + + assert "dashboards" in result + assert "charts" in result + assert "datasets" in result + assert len(result["dashboards"]) == 1 + assert result["dashboards"][0]["uuid"] == "dash-uuid-1" + assert len(result["charts"]) == 1 + assert result["charts"][0]["uuid"] == "chart-uuid-1" + assert len(result["datasets"]) == 1 + assert result["datasets"][0]["uuid"] == "ds-uuid-1" + + def test_empty_zip_returns_empty_lists(self): + from src.core.migration.archive_parser import MigrationArchiveParser + + parser = MigrationArchiveParser() + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = PathLib(tmpdir) + zip_path = tmp / "empty.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + pass # Empty ZIP + + result = parser.extract_objects_from_zip(str(zip_path)) + + assert result == {"dashboards": [], "charts": [], "datasets": []} + + def test_nonexistent_file_raises(self): + from src.core.migration.archive_parser import MigrationArchiveParser + + parser = MigrationArchiveParser() + + with pytest.raises(FileNotFoundError): + parser.extract_objects_from_zip("/nonexistent/path.zip") + + +class TestCollectYamlObjects: + """Verify _collect_yaml_objects.""" + + def test_collects_valid_yaml(self): + from src.core.migration.archive_parser import MigrationArchiveParser + + parser = MigrationArchiveParser() + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = PathLib(tmpdir) + dash_dir = tmp / "dashboards" + dash_dir.mkdir(parents=True) + with open(dash_dir / "valid.yaml", "w") as f: + yaml.dump({"uuid": "abc-123", "dashboard_title": "Test"}, f) + with open(dash_dir / "invalid.yaml", "w") as f: + f.write("not: valid: yaml: [\n") + + result = parser._collect_yaml_objects(tmp, "dashboards") + assert len(result) == 1 + assert result[0]["uuid"] == "abc-123" + + def test_skip_invalid_yaml(self): + from src.core.migration.archive_parser import MigrationArchiveParser + + parser = MigrationArchiveParser() + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = PathLib(tmpdir) + dash_dir = tmp / "dashboards" + dash_dir.mkdir(parents=True) + with open(dash_dir / "bad.yaml", "w") as f: + f.write("{{{invalid_yaml\n") + + # Should not raise, should silently skip + result = parser._collect_yaml_objects(tmp, "dashboards") + assert result == [] + + +class TestNormalizeObjectPayload: + """Verify _normalize_object_payload for different object types.""" + + @pytest.fixture + def parser(self): + from src.core.migration.archive_parser import MigrationArchiveParser + + return MigrationArchiveParser() + + def test_dashboard_normalization(self, parser): + payload = { + "uuid": "dash-1", + "dashboard_title": "Sales Dashboard", + "slug": "sales", + "description": "Sales data", + "owners": [{"id": 1, "username": "admin"}], + } + result = parser._normalize_object_payload(payload, "dashboards") + assert result is not None + assert result["uuid"] == "dash-1" + assert result["title"] == "Sales Dashboard" + assert "signature" in result + sig = json.loads(result["signature"]) + assert sig["slug"] == "sales" + assert result["owners"] == [{"id": 1, "username": "admin"}] + + def test_dashboard_no_title_fallback(self, parser): + payload = {"uuid": "dash-2"} + result = parser._normalize_object_payload(payload, "dashboards") + assert result is not None + assert result["title"] == "Dashboard dash-2" + + def test_chart_normalization(self, parser): + payload = { + "uuid": "chart-1", + "slice_name": "Revenue", + "viz_type": "bar", + "params": "{}", + "datasource_uuid": "ds-1", + } + result = parser._normalize_object_payload(payload, "charts") + assert result is not None + assert result["uuid"] == "chart-1" + assert result["title"] == "Revenue" + assert result["dataset_uuid"] == "ds-1" + sig = json.loads(result["signature"]) + assert sig["viz_type"] == "bar" + + def test_chart_no_name_fallback(self, parser): + payload = {"uuid": "chart-2"} + result = parser._normalize_object_payload(payload, "charts") + assert result is not None + assert result["title"] == "Chart chart-2" + + def test_dataset_normalization(self, parser): + payload = { + "uuid": "ds-1", + "table_name": "orders", + "schema": "public", + "database_uuid": "db-1", + "sql": "SELECT * FROM orders", + } + result = parser._normalize_object_payload(payload, "datasets") + assert result is not None + assert result["uuid"] == "ds-1" + assert result["title"] == "orders" + assert result["database_uuid"] == "db-1" + sig = json.loads(result["signature"]) + assert sig["schema"] == "public" + + def test_dataset_no_name_fallback(self, parser): + payload = {"uuid": "ds-2", "database_uuid": "db-1"} + result = parser._normalize_object_payload(payload, "datasets") + assert result is not None + assert result["title"] == "Dataset ds-2" + + def test_non_dict_payload_returns_none(self, parser): + result = parser._normalize_object_payload("not a dict", "dashboards") + assert result is None + + def test_missing_uuid_returns_none(self, parser): + result = parser._normalize_object_payload({"dashboard_title": "No UUID"}, "dashboards") + assert result is None + + def test_unknown_type_returns_none(self, parser): + payload = {"uuid": "unknown-1", "name": "Something"} + result = parser._normalize_object_payload(payload, "unknown_type") + assert result is None + + def test_chart_with_dataset_uuid_alias(self, parser): + payload = {"uuid": "chart-3", "slice_name": "Test", "dataset_uuid": "ds-alias"} + result = parser._normalize_object_payload(payload, "charts") + assert result is not None + assert result["dataset_uuid"] == "ds-alias" + + def test_dashboard_owners_none_becomes_empty(self, parser): + payload = {"uuid": "dash-3", "dashboard_title": "Test"} + result = parser._normalize_object_payload(payload, "dashboards") + assert result is not None + assert result["owners"] == [] +# #endregion Test.MigrationArchiveParser diff --git a/backend/tests/test_core/test_async_superset_client.py b/backend/tests/test_core/test_async_superset_client.py new file mode 100644 index 00000000..80a7c52f --- /dev/null +++ b/backend/tests/test_core/test_async_superset_client.py @@ -0,0 +1,80 @@ +# #region Test.AsyncSupersetClient [C:3] [TYPE Module] [SEMANTICS test,superset,async,client] +# @BRIEF Tests for core/async_superset_client.py — AsyncSupersetClient. +# @RELATION BINDS_TO -> [AsyncSupersetClientModule] +# @TEST_EDGE: aclose_called -> delegates to client.aclose + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from unittest.mock import AsyncMock, MagicMock, patch +import pytest + + +class TestAsyncSupersetClient: + """Verify AsyncSupersetClient backward-compat wrapper.""" + + def test_init_delegates_to_superset_client(self): + """__init__ calls super().__init__ with env.""" + from src.core.async_superset_client import AsyncSupersetClient + from src.core.config_models import Environment + + env = MagicMock(spec=Environment) + env.id = "test-env" + env.name = "Test" + env.url = "https://test.example.com" + env.username = "admin" + env.password = "pass" + env.verify_ssl = True + env.timeout = 30 + + with patch("src.core.superset_client._base.SupersetClientBase.__init__") as mock_init: + mock_init.return_value = None + client = AsyncSupersetClient(env) + mock_init.assert_called_once_with(env) + + async def test_aclose_calls_client_aclose(self): + from src.core.async_superset_client import AsyncSupersetClient + from src.core.config_models import Environment + + env = MagicMock(spec=Environment) + env.id = "test-env" + env.name = "Test" + env.url = "https://test.example.com" + env.username = "admin" + env.password = "pass" + env.verify_ssl = True + env.timeout = 30 + + client = AsyncSupersetClient.__new__(AsyncSupersetClient) + client.client = MagicMock() + client.client.aclose = AsyncMock() + + await client.aclose() + client.client.aclose.assert_awaited_once() + + async def test_aclose_on_real_init(self): + """Integration: init with env, then aclose should work.""" + from src.core.config_models import Environment + + env = Environment( + id="test-env", + name="Test", + url="https://test.example.com", + username="admin", + password="pass", + verify_ssl=False, + timeout=10, + ) + + with patch("src.core.superset_client._base.SupersetClientBase.__init__") as mock_init: + mock_init.return_value = None + from src.core.async_superset_client import AsyncSupersetClient + + client = AsyncSupersetClient.__new__(AsyncSupersetClient) + client.client = MagicMock() + client.client.aclose = AsyncMock() + await client.aclose() + client.client.aclose.assert_awaited_once() +# #endregion Test.AsyncSupersetClient diff --git a/backend/tests/test_core/test_auth_repository.py b/backend/tests/test_core/test_auth_repository.py new file mode 100644 index 00000000..fcc3058c --- /dev/null +++ b/backend/tests/test_core/test_auth_repository.py @@ -0,0 +1,194 @@ +# #region Test.Auth.Repository [C:3] [TYPE Module] [SEMANTICS test,auth,repository,sqlalchemy] +# @BRIEF Tests for core/auth/repository.py — AuthRepository CRUD methods. +# @RELATION BINDS_TO -> [AuthRepositoryModule] +# @TEST_EDGE: user_not_found -> returns None +# @TEST_EDGE: empty_ad_groups -> returns [] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import os + +os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("JWT_AUDIENCE", "test-audience") +os.environ.setdefault("JWT_ISSUER", "test-issuer") + +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import Session + + +@pytest.fixture(scope="module") +def engine(): + eng = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + event.listen(eng, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) + from src.models.mapping import Base + Base.metadata.create_all(bind=eng) + return eng + + +@pytest.fixture +def db(engine): + session = Session(bind=engine) + yield session + session.rollback() + # Clean up all tables after each test + for table in reversed(list(Base.metadata.sorted_tables)): + session.execute(table.delete()) + session.commit() + session.close() + + +# Import Base here for cleanup fixture +from src.models.mapping import Base + + +@pytest.fixture +def repo(db): + from src.core.auth.repository import AuthRepository + return AuthRepository(db) + + +@pytest.fixture +def seed_data(db): + """Seed test data into the database.""" + from src.models.auth import User, Role, Permission, ADGroupMapping + from src.models.profile import UserDashboardPreference + + user = User(id="user-1", username="testuser", email="test@example.com", full_name="Test User") + db.add(user) + + role = Role(id="role-1", name="admin", description="Administrator") + db.add(role) + + perm = Permission(id="perm-1", resource="plugin:backup", action="EXECUTE") + db.add(perm) + + # Create mapping + mapping = ADGroupMapping(id="mapping-1", ad_group="AD-Admin", role_id="role-1") + db.add(mapping) + + pref = UserDashboardPreference(user_id="user-1", show_only_my_dashboards=True) + db.add(pref) + + db.commit() + return {"user": user, "role": role, "perm": perm, "pref": pref} + + +class TestGetUserById: + def test_found(self, repo, seed_data): + result = repo.get_user_by_id("user-1") + assert result is not None + assert result.id == "user-1" + assert result.username == "testuser" + + def test_not_found(self, repo): + result = repo.get_user_by_id("nonexistent") + assert result is None + + +class TestGetUserByUsername: + def test_found(self, repo, seed_data): + result = repo.get_user_by_username("testuser") + assert result is not None + assert result.id == "user-1" + + def test_not_found(self, repo): + result = repo.get_user_by_username("unknown") + assert result is None + + +class TestGetRoleById: + def test_found(self, repo, seed_data): + result = repo.get_role_by_id("role-1") + assert result is not None + assert result.name == "admin" + + def test_not_found(self, repo): + result = repo.get_role_by_id("nonexistent") + assert result is None + + +class TestGetRoleByName: + def test_found(self, repo, seed_data): + result = repo.get_role_by_name("admin") + assert result is not None + assert result.id == "role-1" + + def test_not_found(self, repo): + result = repo.get_role_by_name("unknown") + assert result is None + + +class TestGetPermissionById: + def test_found(self, repo, seed_data): + result = repo.get_permission_by_id("perm-1") + assert result is not None + assert result.resource == "plugin:backup" + + def test_not_found(self, repo): + result = repo.get_permission_by_id("nonexistent") + assert result is None + + +class TestGetPermissionByResourceAction: + def test_found(self, repo, seed_data): + result = repo.get_permission_by_resource_action("plugin:backup", "EXECUTE") + assert result is not None + assert result.id == "perm-1" + + def test_not_found(self, repo): + result = repo.get_permission_by_resource_action("unknown", "READ") + assert result is None + + +class TestListPermissions: + def test_returns_all(self, repo, seed_data): + results = repo.list_permissions() + assert len(results) == 1 + assert results[0].id == "perm-1" + + def test_empty(self, repo): + results = repo.list_permissions() + assert results == [] + + +class TestGetUserDashboardPreference: + def test_found(self, repo, seed_data): + result = repo.get_user_dashboard_preference("user-1") + assert result is not None + assert result.user_id == "user-1" + + def test_not_found(self, repo): + result = repo.get_user_dashboard_preference("nonexistent") + assert result is None + + +class TestGetRolesByAdGroups: + def test_empty_groups_returns_empty(self, repo): + result = repo.get_roles_by_ad_groups([]) + assert result == [] + + def test_found(self, repo, seed_data): + result = repo.get_roles_by_ad_groups(["AD-Admin"]) + assert len(result) == 1 + assert result[0].id == "role-1" + + def test_no_match_returns_empty(self, repo): + result = repo.get_roles_by_ad_groups(["AD-Nonexistent"]) + assert result == [] + + def test_multiple_groups(self, repo, seed_data): + from src.models.auth import ADGroupMapping, Role + + # Add another mapping + role2 = Role(id="role-2", name="viewer") + repo.db.add(role2) + repo.db.add(ADGroupMapping(id="mapping-2", ad_group="AD-Viewer", role_id="role-2")) + repo.db.commit() + + result = repo.get_roles_by_ad_groups(["AD-Admin", "AD-Viewer"]) + assert len(result) == 2 +# #endregion Test.Auth.Repository diff --git a/backend/tests/test_core/test_jwt.py b/backend/tests/test_core/test_jwt.py new file mode 100644 index 00000000..3a47cc74 --- /dev/null +++ b/backend/tests/test_core/test_jwt.py @@ -0,0 +1,339 @@ +# #region Test.Auth.Jwt [C:3] [TYPE Module] [SEMANTICS test,auth,jwt,token] +# @BRIEF Tests for core/auth/jwt.py — create_access_token, decode_token, blacklist, is_blacklisted. +# @RELATION BINDS_TO -> [Auth.Jwt] +# @TEST_EDGE: invalid_token -> decode_token raises JWTError +# @TEST_EDGE: expired_token -> decode_token raises JWTError +# @TEST_EDGE: blacklist_missing_jti -> blacklist_token returns early + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import os + +# Must set auth env vars before any src.core.auth imports +os.environ.setdefault("AUTH_SECRET_KEY", "test-secret-key-for-jwt-testing") +os.environ.setdefault("JWT_AUDIENCE", "test-audience") +os.environ.setdefault("JWT_ISSUER", "test-issuer") + +from datetime import UTC, datetime, timedelta +import pytest +from unittest.mock import patch, MagicMock +from jose import jwt as jose_jwt + + +class TestCreateAccessToken: + """Verify create_access_token @POST guarantees.""" + + def test_creates_token_with_minimal_data(self): + from src.core.auth.jwt import create_access_token, decode_token + + token = create_access_token({"sub": "user1"}) + assert isinstance(token, str) + payload = decode_token(token) + assert payload["sub"] == "user1" + assert "exp" in payload + assert "iat" in payload + assert "jti" in payload + assert "aud" in payload + assert "iss" in payload + + def test_creates_token_with_scopes(self): + from src.core.auth.jwt import create_access_token, decode_token + + token = create_access_token({"sub": "user1", "scopes": ["read", "write"]}) + payload = decode_token(token) + assert payload["scopes"] == ["read", "write"] + + def test_creates_token_with_custom_expiry(self): + from src.core.auth.jwt import create_access_token, decode_token + + token = create_access_token({"sub": "user1"}, expires_delta=timedelta(hours=2)) + payload = decode_token(token) + assert payload["sub"] == "user1" + + def test_token_contains_expected_claims(self): + from src.core.auth.jwt import create_access_token, decode_token, auth_config + + token = create_access_token({"sub": "user1"}) + payload = decode_token(token) + assert payload["aud"] == auth_config.JWT_AUDIENCE + assert payload["iss"] == auth_config.JWT_ISSUER + assert payload["sub"] == "user1" + + +class TestDecodeToken: + """Verify decode_token @POST guarantees.""" + + def test_decode_valid_token(self): + from src.core.auth.jwt import create_access_token, decode_token + + token = create_access_token({"sub": "user1"}) + payload = decode_token(token) + assert payload["sub"] == "user1" + + def test_decode_expired_token_raises(self): + from src.core.auth.jwt import decode_token, auth_config + from jose import jwt as jose_jwt + + expired = jose_jwt.encode( + { + "sub": "user1", + "exp": 0, + "iat": 0, + "jti": "test-jti", + "aud": auth_config.JWT_AUDIENCE, + "iss": auth_config.JWT_ISSUER, + }, + auth_config.SECRET_KEY, + algorithm=auth_config.ALGORITHM, + ) + with pytest.raises(Exception): + decode_token(expired) + + def test_decode_invalid_signature_raises(self): + from src.core.auth.jwt import decode_token + + # Token with different key + bad_token = jose_jwt.encode( + {"sub": "user1", "exp": 9999999999, "iat": 0, "jti": "test", "aud": "test-audience", "iss": "test-issuer"}, + "wrong-secret-key", + algorithm="HS256", + ) + with pytest.raises(Exception): + decode_token(bad_token) + + def test_decode_malformed_token_raises(self): + from src.core.auth.jwt import decode_token + + with pytest.raises(Exception): + decode_token("not-a-jwt-token") + + def test_decode_missing_required_claims_raises(self): + from src.core.auth.jwt import decode_token, auth_config + + # Token missing 'sub' — jose may or may not enforce require for sub + # Test with a genuinely invalid token instead + with pytest.raises(Exception): + decode_token("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.missing-parts") + + def test_decode_empty_token_raises(self): + from src.core.auth.jwt import decode_token + + with pytest.raises(Exception): + decode_token("") + + +class TestHashToken: + """Verify _hash_token utility.""" + + def test_hash_token_returns_sha256_hex(self): + from src.core.auth.jwt import _hash_token + + result = _hash_token("test-token") + assert isinstance(result, str) + assert len(result) == 64 # SHA-256 hex digest length + + def test_hash_token_is_deterministic(self): + from src.core.auth.jwt import _hash_token + + assert _hash_token("same-token") == _hash_token("same-token") + + def test_hash_token_different_for_different_inputs(self): + from src.core.auth.jwt import _hash_token + + assert _hash_token("token-a") != _hash_token("token-b") + + +class TestPruneBlacklist: + """Verify _prune_blacklist deletes expired entries.""" + + def test_deletes_expired_entries(self): + from src.core.auth.jwt import _prune_blacklist + from src.models.mapping import Base + from src.models.auth import TokenBlacklist + from sqlalchemy import create_engine, event + from sqlalchemy.orm import Session + + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) + Base.metadata.create_all(bind=engine) + db = Session(bind=engine) + + past = datetime.now(UTC) - timedelta(hours=2) + db.add(TokenBlacklist(jti="expired1", token_hash="a" * 64, expires_at=past)) + db.add(TokenBlacklist(jti="expired2", token_hash="b" * 64, expires_at=past)) + db.commit() + + _prune_blacklist(db) + + remaining = db.query(TokenBlacklist).all() + assert len(remaining) == 0 + + def test_keeps_future_entries(self): + from src.core.auth.jwt import _prune_blacklist + from src.models.mapping import Base + from src.models.auth import TokenBlacklist + from sqlalchemy import create_engine, event + from sqlalchemy.orm import Session + + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) + Base.metadata.create_all(bind=engine) + db = Session(bind=engine) + + future = datetime.now(UTC) + timedelta(hours=2) + db.add(TokenBlacklist(jti="valid1", token_hash="c" * 64, expires_at=future)) + db.commit() + + _prune_blacklist(db) + + remaining = db.query(TokenBlacklist).all() + assert len(remaining) == 1 + assert remaining[0].jti == "valid1" + + def test_empty_db_does_not_raise(self): + from src.core.auth.jwt import _prune_blacklist + from src.models.mapping import Base + from sqlalchemy import create_engine, event + from sqlalchemy.orm import Session + + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) + Base.metadata.create_all(bind=engine) + db = Session(bind=engine) + + # Should not raise + _prune_blacklist(db) + + +class TestBlacklistToken: + """Verify blacklist_token @POST guarantees.""" + + @pytest.fixture + def db_session(self): + from src.models.mapping import Base + from sqlalchemy import create_engine, event + from sqlalchemy.orm import Session + + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) + Base.metadata.create_all(bind=engine) + session = Session(bind=engine) + yield session + session.close() + engine.dispose() + + def test_blacklist_valid_token(self, db_session): + from src.core.auth.jwt import create_access_token, blacklist_token, is_token_blacklisted + from src.models.auth import TokenBlacklist + + token = create_access_token({"sub": "user1"}) + blacklist_token(token, db_session) + + assert is_token_blacklisted(token, db_session) is True + entry = db_session.query(TokenBlacklist).first() + assert entry is not None + assert entry.jti is not None + + def test_blacklist_invalid_token_skips(self, db_session): + from src.core.auth.jwt import blacklist_token + from src.models.auth import TokenBlacklist + + # Should return early without error + blacklist_token("invalid-token", db_session) + count = db_session.query(TokenBlacklist).count() + assert count == 0 + + def test_blacklist_existing_entry_skips(self, db_session): + from src.core.auth.jwt import create_access_token, blacklist_token + from src.models.auth import TokenBlacklist + + token = create_access_token({"sub": "user1"}) + blacklist_token(token, db_session) + # Blacklist again — should skip without error + blacklist_token(token, db_session) + count = db_session.query(TokenBlacklist).count() + assert count == 1 + + def test_blacklist_token_missing_jti_returns(self, db_session): + """Covers line 133: decode succeeds but payload has no jti.""" + from src.core.auth.jwt import blacklist_token + from src.models.auth import TokenBlacklist + from unittest.mock import patch + + with patch("src.core.auth.jwt.decode_token", return_value={"sub": "user1"}): + blacklist_token("some-token", db_session) + count = db_session.query(TokenBlacklist).count() + assert count == 0 + + def test_blacklist_token_with_datetime_exp_works(self, db_session): + """Covers line 136: exp_raw is a datetime instance.""" + from src.core.auth.jwt import blacklist_token, is_token_blacklisted + from src.models.auth import TokenBlacklist + from unittest.mock import patch + from datetime import datetime, timedelta + + # Use naive datetime since SQLite stores datetimes without timezone + exp_dt = datetime.now() + timedelta(hours=1) + with patch("src.core.auth.jwt.decode_token", return_value={ + "sub": "user1", "jti": "test-jti-dt", "exp": exp_dt, + }): + blacklist_token("some-token", db_session) + entry = db_session.query(TokenBlacklist).filter(TokenBlacklist.jti == "test-jti-dt").first() + assert entry is not None + # SQLite stores datetime precision differently, compare timestamps + assert abs((entry.expires_at - exp_dt).total_seconds()) < 1 + + +class TestIsTokenBlacklisted: + """Verify is_token_blacklisted @POST guarantees.""" + + @pytest.fixture + def db_session(self): + from src.models.mapping import Base + from sqlalchemy import create_engine, event + from sqlalchemy.orm import Session + + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + event.listen(engine, "connect", lambda c, _: c.execute("PRAGMA foreign_keys=ON")) + Base.metadata.create_all(bind=engine) + session = Session(bind=engine) + yield session + session.close() + engine.dispose() + + def test_not_blacklisted_returns_false(self, db_session): + from src.core.auth.jwt import create_access_token, is_token_blacklisted + + token = create_access_token({"sub": "user1"}) + assert is_token_blacklisted(token, db_session) is False + + def test_blacklisted_returns_true(self, db_session): + from src.core.auth.jwt import create_access_token, blacklist_token, is_token_blacklisted + + token = create_access_token({"sub": "user1"}) + blacklist_token(token, db_session) + assert is_token_blacklisted(token, db_session) is True + + def test_invalid_token_returns_true(self, db_session): + from src.core.auth.jwt import is_token_blacklisted + + assert is_token_blacklisted("invalid-token", db_session) is True + + def test_token_missing_jti_returns_false(self, db_session): + """Covers line 171: decode succeeds but payload has no jti.""" + from src.core.auth.jwt import is_token_blacklisted + from unittest.mock import patch + + with patch("src.core.auth.jwt.decode_token", return_value={"sub": "user1"}): + result = is_token_blacklisted("some-token", db_session) + assert result is False + + def test_deleted_token_returns_true(self, db_session): + from src.core.auth.jwt import is_token_blacklisted + + # Token missing jti claim + assert is_token_blacklisted("malformed.token.here", db_session) is True +# #endregion Test.Auth.Jwt diff --git a/backend/tests/test_core/test_plugin_base.py b/backend/tests/test_core/test_plugin_base.py new file mode 100644 index 00000000..f5bad97e --- /dev/null +++ b/backend/tests/test_core/test_plugin_base.py @@ -0,0 +1,204 @@ +# #region Test.PluginBase [C:3] [TYPE Module] [SEMANTICS test,plugin,base,abstract] +# @BRIEF Tests for core/plugin_base.py — PluginBase, PluginConfig. +# @RELATION BINDS_TO -> [PluginBase] + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import pytest +from typing import Any + + +class TestPluginBase: + """Verify PluginBase ABC contracts.""" + + def test_cannot_instantiate_abstract_class(self): + from src.core.plugin_base import PluginBase + + with pytest.raises(TypeError): + PluginBase() + + def test_concrete_plugin_works(self): + from src.core.plugin_base import PluginBase + + class TestPlugin(PluginBase): + @property + def id(self) -> str: + return "test-plugin" + + @property + def name(self) -> str: + return "Test Plugin" + + @property + def description(self) -> str: + return "A test plugin" + + @property + def version(self) -> str: + return "1.0.0" + + def get_schema(self) -> dict[str, Any]: + return {"type": "object", "properties": {}} + + async def execute(self, params: dict[str, Any]): + return {"status": "ok"} + + plugin = TestPlugin() + assert plugin.id == "test-plugin" + assert plugin.name == "Test Plugin" + assert plugin.description == "A test plugin" + assert plugin.version == "1.0.0" + + def test_required_permission_format(self): + from src.core.plugin_base import PluginBase + + class TestPlugin(PluginBase): + @property + def id(self) -> str: + return "backup" + + @property + def name(self) -> str: + return "Backup" + + @property + def description(self) -> str: + return "Backup plugin" + + @property + def version(self) -> str: + return "1.0" + + def get_schema(self) -> dict[str, Any]: + return {} + + async def execute(self, params: dict[str, Any]): + pass + + plugin = TestPlugin() + assert plugin.required_permission == "plugin:backup:execute" + + def test_ui_route_default_is_none(self): + from src.core.plugin_base import PluginBase + + class TestPlugin(PluginBase): + @property + def id(self) -> str: + return "test" + + @property + def name(self) -> str: + return "Test" + + @property + def description(self) -> str: + return "Test" + + @property + def version(self) -> str: + return "1.0" + + def get_schema(self) -> dict[str, Any]: + return {} + + async def execute(self, params: dict[str, Any]): + pass + + plugin = TestPlugin() + assert plugin.ui_route is None + + +class TestPluginBaseAbstractBodies: + """Cover abstract method bodies by calling super().""" + + @pytest.mark.asyncio + async def test_id_abstract_body(self): + from src.core.plugin_base import PluginBase + + class TestPlugin(PluginBase): + @property + def id(self): + return super().id + + @property + def name(self): + return super().name + + @property + def description(self): + return super().description + + @property + def version(self): + return super().version + + def get_schema(self): + return super().get_schema() + + async def execute(self, params): + return await super().execute(params) + + plugin = TestPlugin() + # These should execute the abstract bodies (with belief_scope: pass) + assert plugin.id is None + assert plugin.name is None + assert plugin.description is None + assert plugin.version is None + assert plugin.get_schema() is None + result = await plugin.execute({}) + assert result is None + + +class TestPluginConfig: + """Verify PluginConfig Pydantic model.""" + + def test_full_config(self): + from src.core.plugin_base import PluginConfig + + config = PluginConfig( + id="backup", + name="Backup Plugin", + description="Creates backups", + version="2.1.0", + ui_route="/plugins/backup", + schema={"type": "object"}, + ) + assert config.id == "backup" + assert config.name == "Backup Plugin" + assert config.description == "Creates backups" + assert config.version == "2.1.0" + assert config.ui_route == "/plugins/backup" + assert config.input_schema == {"type": "object"} + + def test_minimal_config(self): + from src.core.plugin_base import PluginConfig + + config = PluginConfig( + id="simple", + name="Simple", + description="Simple plugin", + version="1.0", + schema={}, + ) + assert config.id == "simple" + assert config.ui_route is None + assert config.input_schema == {} + + def test_config_serialization(self): + from src.core.plugin_base import PluginConfig + + config = PluginConfig( + id="test", + name="Test", + description="A plugin", + version="0.1", + schema={"type": "object", "properties": {"x": {"type": "string"}}}, + ) + data = config.model_dump(by_alias=True) + assert data["id"] == "test" + assert data["schema"] == {"type": "object", "properties": {"x": {"type": "string"}}} + assert data["ui_route"] is None +# #endregion Test.PluginBase diff --git a/backend/tests/test_core/test_superset_charts.py b/backend/tests/test_core/test_superset_charts.py new file mode 100644 index 00000000..26ab9ba3 --- /dev/null +++ b/backend/tests/test_core/test_superset_charts.py @@ -0,0 +1,150 @@ +# #region Test.Superset.Charts [C:3] [TYPE Module] [SEMANTICS test,superset,charts] +# @BRIEF Tests for core/superset_client/_charts.py — SupersetChartsMixin. +# @RELATION BINDS_TO -> [SupersetChartsMixin] +# @TEST_EDGE: extract_chart_ids_non_dict_walks -> empty set +# @TEST_EDGE: extract_chart_ids_chart_n_pattern -> CHART-123 format + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from unittest.mock import AsyncMock, MagicMock, patch +import pytest + + +class TestGetChart: + """Verify get_chart method.""" + + @pytest.fixture + def mixin(self): + from src.core.superset_client._charts import SupersetChartsMixin + + m = SupersetChartsMixin() + m.client = MagicMock() + m.client.request = AsyncMock(return_value={"id": 42, "slice_name": "Test"}) + return m + + async def test_get_chart_returns_dict(self, mixin): + result = await mixin.get_chart(42) + assert result["id"] == 42 + assert result["slice_name"] == "Test" + mixin.client.request.assert_called_once_with(method="GET", endpoint="/chart/42") + + +class TestGetCharts: + """Verify get_charts method.""" + + @pytest.fixture + def mixin(self): + from src.core.superset_client._charts import SupersetChartsMixin + + m = SupersetChartsMixin() + m.client = MagicMock() + m._validate_query_params = MagicMock(side_effect=lambda q: q or {}) + m._fetch_all_pages = AsyncMock( + return_value=[{"id": 1, "slice_name": "Chart 1"}, {"id": 2, "slice_name": "Chart 2"}] + ) + return m + + async def test_get_charts_returns_count_and_list(self, mixin): + total, charts = await mixin.get_charts() + assert total == 2 + assert len(charts) == 2 + mixin._fetch_all_pages.assert_called_once() + + async def test_get_charts_with_query_params(self, mixin): + total, charts = await mixin.get_charts(query={"page": 0, "page_size": 50}) + assert total == 2 + + async def test_get_charts_adds_default_columns(self, mixin): + mixin._validate_query_params = MagicMock(return_value={}) + total, charts = await mixin.get_charts() + mixin._fetch_all_pages.assert_called_once_with( + endpoint="/chart/", + pagination_options={ + "base_query": {"columns": ["id", "uuid", "slice_name", "viz_type"]}, + "results_field": "result", + }, + ) + + +class TestExtractChartIdsFromLayout: + """Verify _extract_chart_ids_from_layout.""" + + @pytest.fixture + def mixin(self): + from src.core.superset_client._charts import SupersetChartsMixin + + return SupersetChartsMixin() + + def test_extracts_chart_id_key(self, mixin): + payload = {"chartId": 42} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == {42} + + def test_extracts_chart_id_key_variant(self, mixin): + payload = {"chart_id": 99} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == {99} + + def test_extracts_slice_id(self, mixin): + payload = {"slice_id": 77} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == {77} + + def test_extracts_sliceId(self, mixin): + payload = {"sliceId": 55} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == {55} + + def test_extracts_chart_n_pattern(self, mixin): + payload = {"id": "CHART-123"} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == {123} + + def test_ignores_non_matching_string_id(self, mixin): + payload = {"id": "some-other-id"} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == set() + + def test_handles_nested_dicts(self, mixin): + payload = {"children": {"child": {"chartId": 10}}, "other": {"slice_id": 20}} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == {10, 20} + + def test_handles_lists(self, mixin): + payload = {"rows": [{"chartId": 1}, {"chartId": 2}]} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == {1, 2} + + def test_empty_payload(self, mixin): + result = mixin._extract_chart_ids_from_layout({}) + assert result == set() + + def test_non_dict_payload(self, mixin): + result = mixin._extract_chart_ids_from_layout("string") + assert result == set() + + def test_none_payload(self, mixin): + result = mixin._extract_chart_ids_from_layout(None) + assert result == set() + + def test_non_parseable_chart_n(self, mixin): + payload = {"id": "CHART-abc"} # non-numeric + result = mixin._extract_chart_ids_from_layout(payload) + assert result == set() + + def test_non_parseable_chart_id_value(self, mixin): + payload = {"chartId": "not-a-number"} + result = mixin._extract_chart_ids_from_layout(payload) + assert result == set() + + def test_int_conversion_error_in_chart_n(self, mixin): + """Cover lines 68-69: int() on CHART-N digits fails (defensive).""" + from unittest.mock import patch + + with patch("src.core.superset_client._charts.int", side_effect=ValueError("mock fail")): + result = mixin._extract_chart_ids_from_layout({"id": "CHART-123"}) + assert result == set() +# #endregion Test.Superset.Charts diff --git a/backend/tests/test_core/test_superset_user_projection.py b/backend/tests/test_core/test_superset_user_projection.py new file mode 100644 index 00000000..a7f7ec30 --- /dev/null +++ b/backend/tests/test_core/test_superset_user_projection.py @@ -0,0 +1,158 @@ +# #region Test.Superset.UserProjection [C:3] [TYPE Module] [SEMANTICS test,superset,user,projection] +# @BRIEF Tests for core/superset_client/_user_projection.py — SupersetUserProjectionMixin. +# @RELATION BINDS_TO -> [SupersetUserProjection] +# @TEST_EDGE: owners_payload_none -> [] +# @TEST_EDGE: owner_single_dict_wrapped -> normalize as list + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from unittest.mock import MagicMock +import pytest + + +class TestSanitizeUserText: + """Verify _sanitize_user_text.""" + + @pytest.fixture + def mixin(self): + from src.core.superset_client._user_projection import SupersetUserProjectionMixin + + return SupersetUserProjectionMixin() + + def test_none_returns_none(self, mixin): + assert mixin._sanitize_user_text(None) is None + + def test_empty_string_returns_none(self, mixin): + assert mixin._sanitize_user_text("") is None + + def test_whitespace_string_returns_none(self, mixin): + assert mixin._sanitize_user_text(" ") is None + + def test_valid_string_stripped(self, mixin): + assert mixin._sanitize_user_text(" John ") == "John" + + def test_int_converted_to_string(self, mixin): + assert mixin._sanitize_user_text(123) == "123" + + +class TestExtractUserDisplay: + """Verify _extract_user_display.""" + + @pytest.fixture + def mixin(self): + from src.core.superset_client._user_projection import SupersetUserProjectionMixin + + return SupersetUserProjectionMixin() + + def test_preferred_value_used(self, mixin): + result = mixin._extract_user_display("Preferred", {"full_name": "Ignored"}) + assert result == "Preferred" + + def test_preferred_value_whitespace_skipped(self, mixin): + result = mixin._extract_user_display(" ", {"full_name": "John Doe"}) + assert result == "John Doe" + + def test_full_name_used(self, mixin): + result = mixin._extract_user_display(None, {"full_name": "John Doe"}) + assert result == "John Doe" + + def test_first_name_last_name_combined(self, mixin): + result = mixin._extract_user_display(None, {"first_name": "John", "last_name": "Doe"}) + assert result == "John Doe" + + def test_first_name_only(self, mixin): + result = mixin._extract_user_display(None, {"first_name": "John"}) + assert result == "John" + + def test_last_name_only(self, mixin): + result = mixin._extract_user_display(None, {"last_name": "Doe"}) + assert result == "Doe" + + def test_username_fallback(self, mixin): + result = mixin._extract_user_display(None, {"username": "johndoe"}) + assert result == "johndoe" + + def test_email_fallback(self, mixin): + result = mixin._extract_user_display(None, {"email": "john@example.com"}) + assert result == "john@example.com" + + def test_none_payload_returns_none(self, mixin): + result = mixin._extract_user_display(None, None) + assert result is None + + def test_empty_dict_returns_none(self, mixin): + result = mixin._extract_user_display(None, {}) + assert result is None + + def test_non_dict_payload_returns_none(self, mixin): + result = mixin._extract_user_display(None, "string") + assert result is None + + +class TestExtractOwnerLabels: + """Verify _extract_owner_labels.""" + + @pytest.fixture + def mixin(self): + from src.core.superset_client._user_projection import SupersetUserProjectionMixin + + m = SupersetUserProjectionMixin() + m._extract_user_display = MagicMock(return_value="John Doe") # noqa: SIM300 + m._sanitize_user_text = MagicMock(side_effect=lambda x: str(x).strip() if x else None) + return m + + def test_none_owners_returns_empty(self, mixin): + result = mixin._extract_owner_labels(None) + assert result == [] + + def test_list_of_dicts(self, mixin): + result = mixin._extract_owner_labels([{"id": 1}, {"id": 2}]) + assert len(result) >= 1 # extract_user_display returns "John Doe" for each + + def test_single_dict_wrapped_in_list(self, mixin): + # When a single dict is passed, the code wraps it in a list + mixin._extract_user_display = MagicMock(return_value="Single Owner") + result = mixin._extract_owner_labels([{"id": 1}]) + assert "Single Owner" in result + + def test_deduplicates_labels(self, mixin): + mixin._extract_user_display = MagicMock(return_value="Same Person") + result = mixin._extract_owner_labels([{"id": 1}, {"id": 2}]) + assert result == ["Same Person"] # deduplicated + + def test_non_list_wrapped_single(self, mixin): + mixin._extract_user_display = MagicMock(return_value="Owner") + # When owners_payload is not a list, it's wrapped in a list + # But the code checks isinstance(owners_payload, list) first + # To test the else branch, we need non-list, non-None input + mixin._sanitize_user_text = MagicMock(side_effect=lambda x: x if isinstance(x, str) else None) + mixin._extract_user_display = MagicMock(return_value=None) + result = mixin._extract_owner_labels("string_value") + # The else branch sanitizes the scalar + assert "string_value" in result + + def test_extract_user_display_none_not_added(self, mixin): + mixin._extract_user_display = MagicMock(return_value=None) + mixin._sanitize_user_text = MagicMock(return_value=None) + result = mixin._extract_owner_labels([{"id": 1}]) + assert result == [] + + +class TestSanitizeUserTextDirect: + """Direct tests for _sanitize_user_text with the real method.""" + + @pytest.fixture + def mixin(self): + from src.core.superset_client._user_projection import SupersetUserProjectionMixin + + return SupersetUserProjectionMixin() + + def test_whitespace_only_returns_none(self, mixin): + assert mixin._sanitize_user_text(" ") is None + + def test_leading_trailing_spaces_removed(self, mixin): + assert mixin._sanitize_user_text(" hello ") == "hello" +# #endregion Test.Superset.UserProjection diff --git a/backend/tests/test_core/test_task_context.py b/backend/tests/test_core/test_task_context.py new file mode 100644 index 00000000..7283eb46 --- /dev/null +++ b/backend/tests/test_core/test_task_context.py @@ -0,0 +1,136 @@ +# #region Test.TaskContext [C:3] [TYPE Module] [SEMANTICS test,task,context] +# @BRIEF Tests for core/task_manager/context.py — TaskContext class. +# @RELATION BINDS_TO -> [TaskContextModule] +# @TEST_EDGE: get_param_default -> returns default when key missing +# @TEST_EDGE: create_sub_context_preserves_params + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +from unittest.mock import MagicMock, AsyncMock +import pytest + + +class TestTaskContextInit: + """Verify TaskContext.__init__.""" + + def test_initializes_with_required_params(self): + from src.core.task_manager.context import TaskContext + + add_log = MagicMock() + ctx = TaskContext(task_id="task-1", add_log_fn=add_log, params={"key": "val"}) + assert ctx._task_id == "task-1" + assert ctx._params == {"key": "val"} + assert ctx._background_tasks is None + assert ctx._logger is not None + assert ctx._logger._task_id == "task-1" + + def test_initializes_with_custom_source(self): + from src.core.task_manager.context import TaskContext + + ctx = TaskContext(task_id="task-1", add_log_fn=MagicMock(), params={}, default_source="custom") + assert ctx._logger._default_source == "custom" + + def test_initializes_with_background_tasks(self): + from src.core.task_manager.context import TaskContext + + bt = MagicMock() + ctx = TaskContext(task_id="task-1", add_log_fn=MagicMock(), params={}, background_tasks=bt) + assert ctx._background_tasks is bt + + +class TestTaskContextProperties: + """Verify property accessors.""" + + @pytest.fixture + def ctx(self): + from src.core.task_manager.context import TaskContext + + return TaskContext( + task_id="task-1", + add_log_fn=MagicMock(), + params={"key": "val", "num": 42}, + background_tasks="bg", + default_source="plugin", + ) + + def test_task_id_property(self, ctx): + assert ctx.task_id == "task-1" + + def test_logger_property(self, ctx): + from src.core.task_manager.task_logger import TaskLogger + + assert isinstance(ctx.logger, TaskLogger) + assert ctx.logger._task_id == "task-1" + + def test_params_property(self, ctx): + assert ctx.params == {"key": "val", "num": 42} + + def test_background_tasks_property(self, ctx): + assert ctx.background_tasks == "bg" + + def test_background_tasks_none(self): + from src.core.task_manager.context import TaskContext + + ctx = TaskContext(task_id="t", add_log_fn=MagicMock(), params={}) + assert ctx.background_tasks is None + + +class TestTaskContextGetParam: + """Verify get_param method.""" + + @pytest.fixture + def ctx(self): + from src.core.task_manager.context import TaskContext + + return TaskContext(task_id="task-1", add_log_fn=MagicMock(), params={"key": "val"}) + + def test_get_existing_key(self, ctx): + assert ctx.get_param("key") == "val" + + def test_get_missing_key_returns_none(self, ctx): + assert ctx.get_param("nonexistent") is None + + def test_get_missing_key_with_default(self, ctx): + assert ctx.get_param("nonexistent", "default") == "default" + + def test_get_with_none_default(self, ctx): + assert ctx.get_param("nonexistent", None) is None + + +class TestTaskContextCreateSubContext: + """Verify create_sub_context method.""" + + def test_creates_sub_context_with_different_source(self): + from src.core.task_manager.context import TaskContext + + add_log = MagicMock() + ctx = TaskContext(task_id="task-1", add_log_fn=add_log, params={"key": "val"}, default_source="plugin") + sub = ctx.create_sub_context("sub-plugin") + + assert isinstance(sub, TaskContext) + assert sub.task_id == "task-1" + assert sub.params == {"key": "val"} + assert sub._logger._default_source == "sub-plugin" + assert sub._logger._add_log is add_log + + def test_sub_context_preserves_background_tasks(self): + from src.core.task_manager.context import TaskContext + + bt = MagicMock() + ctx = TaskContext(task_id="task-1", add_log_fn=MagicMock(), params={}, background_tasks=bt) + sub = ctx.create_sub_context("sub") + assert sub.background_tasks is bt + + def test_sub_context_independent_logger(self): + from src.core.task_manager.context import TaskContext + + ctx = TaskContext(task_id="task-1", add_log_fn=MagicMock(), params={}) + sub = ctx.create_sub_context("sub") + + assert sub.logger is not ctx.logger + assert sub.logger._default_source == "sub" + assert ctx.logger._default_source == "plugin" +# #endregion Test.TaskContext diff --git a/backend/tests/test_core/test_task_logger.py b/backend/tests/test_core/test_task_logger.py new file mode 100644 index 00000000..28218278 --- /dev/null +++ b/backend/tests/test_core/test_task_logger.py @@ -0,0 +1,197 @@ +# #region Test.TaskLogger [C:3] [TYPE Module] [SEMANTICS test,task,logger] +# @BRIEF Tests for core/task_manager/task_logger.py — TaskLogger class. +# @RELATION BINDS_TO -> [TaskLoggerModule] +# @TEST_EDGE: no_event_loop -> _log drops gracefully +# @TEST_EDGE: with_source_creates_independent_logger + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import asyncio +from unittest.mock import MagicMock, AsyncMock, patch as mock_patch +import pytest + + +class TestTaskLoggerInit: + """Verify TaskLogger.__init__.""" + + def test_initializes_with_required_params(self): + from src.core.task_manager.task_logger import TaskLogger + + add_log = MagicMock() + logger = TaskLogger(task_id="abc123", add_log_fn=add_log) + assert logger._task_id == "abc123" + assert logger._add_log is add_log + assert logger._default_source == "plugin" + + def test_initializes_with_custom_source(self): + from src.core.task_manager.task_logger import TaskLogger + + logger = TaskLogger(task_id="abc123", add_log_fn=MagicMock(), source="custom") + assert logger._default_source == "custom" + + +class TestTaskLoggerWithSource: + """Verify with_source creates sub-logger.""" + + def test_with_source_returns_new_logger(self): + from src.core.task_manager.task_logger import TaskLogger + + add_log = MagicMock() + logger = TaskLogger(task_id="abc123", add_log_fn=add_log) + sub = logger.with_source("sub-source") + assert isinstance(sub, TaskLogger) + assert sub._task_id == "abc123" + assert sub._add_log is add_log + assert sub._default_source == "sub-source" + assert sub is not logger + + +class TestTaskLoggerLog: + """Verify _log behavior including fire-and-forget.""" + + @pytest.fixture + def logger(self): + from src.core.task_manager.task_logger import TaskLogger + + # Use MagicMock (not AsyncMock) in sync context to avoid unawaited coroutine warnings + return TaskLogger(task_id="abc123", add_log_fn=MagicMock(), source="test") + + def test_log_calls_add_log_fn(self, logger): + logger._log("INFO", "test message") + logger._add_log.assert_called_once_with( + task_id="abc123", + level="INFO", + message="test message", + source="test", + metadata=None, + ) + + def test_log_with_override_source(self, logger): + logger._log("ERROR", "msg", source="override") + logger._add_log.assert_called_once_with( + task_id="abc123", + level="ERROR", + message="msg", + source="override", + metadata=None, + ) + + def test_log_with_metadata(self, logger): + logger._log("INFO", "msg", metadata={"key": "val"}) + logger._add_log.assert_called_once_with( + task_id="abc123", + level="INFO", + message="msg", + source="test", + metadata={"key": "val"}, + ) + + def test_log_handles_no_running_loop(self, logger): + """In sync context with no event loop, _log should not raise.""" + # No event loop running — should drop gracefully + logger._log("INFO", "msg") + # Should not raise + + async def test_log_schedules_future_when_loop_running(self): + """When event loop is running, _log schedules ensure_future.""" + from src.core.task_manager.task_logger import TaskLogger + + add_log = AsyncMock() + logger = TaskLogger(task_id="abc123", add_log_fn=add_log) + + with mock_patch.object(asyncio, "ensure_future") as mock_ef: + logger._log("INFO", "msg") + # In async context, ensure_future should be called + assert mock_ef.called + + +class TestTaskLoggerLevelMethods: + """Verify each level method delegates to _log.""" + + @pytest.fixture + def logger(self): + from src.core.task_manager.task_logger import TaskLogger + + # Use MagicMock since _log is patched — no coroutine needed + return TaskLogger(task_id="abc123", add_log_fn=MagicMock(), source="test") + + def test_debug(self, logger): + with pytest.MonkeyPatch().context() as mp: + mock_log = MagicMock() + mp.setattr(logger, "_log", mock_log) + logger.debug("debug msg", source="src", metadata={"a": 1}) + mock_log.assert_called_once_with("DEBUG", "debug msg", "src", {"a": 1}) + + def test_info(self, logger): + with pytest.MonkeyPatch().context() as mp: + mock_log = MagicMock() + mp.setattr(logger, "_log", mock_log) + logger.info("info msg") + mock_log.assert_called_once_with("INFO", "info msg", None, None) + + def test_warning(self, logger): + with pytest.MonkeyPatch().context() as mp: + mock_log = MagicMock() + mp.setattr(logger, "_log", mock_log) + logger.warning("warn msg") + mock_log.assert_called_once_with("WARNING", "warn msg", None, None) + + def test_error(self, logger): + with pytest.MonkeyPatch().context() as mp: + mock_log = MagicMock() + mp.setattr(logger, "_log", mock_log) + logger.error("error msg") + mock_log.assert_called_once_with("ERROR", "error msg", None, None) + + def test_progress_clamps_percent(self, logger): + with pytest.MonkeyPatch().context() as mp: + mock_log = MagicMock() + mp.setattr(logger, "_log", mock_log) + logger.progress("progress", 150) + mock_log.assert_called_once() + args = mock_log.call_args[0] + assert args[0] == "INFO" + assert args[2] is None # source + assert args[3]["progress"] == 100 # clamped + + def test_progress_lower_bound(self, logger): + with pytest.MonkeyPatch().context() as mp: + mock_log = MagicMock() + mp.setattr(logger, "_log", mock_log) + logger.progress("progress", -10) + args = mock_log.call_args[0] + assert args[3]["progress"] == 0 # clamped + + def test_progress_valid(self, logger): + with pytest.MonkeyPatch().context() as mp: + mock_log = MagicMock() + mp.setattr(logger, "_log", mock_log) + logger.progress("halfway", 50) + args = mock_log.call_args[0] + assert args[3]["progress"] == 50 + + +class TestTaskLoggerIntegration: + """Integration tests with real async add_log callback.""" + + async def test_async_add_log_receives_correct_params(self): + from src.core.task_manager.task_logger import TaskLogger + + received = {} + + async def add_log_fn(task_id, level, message, source, metadata): + received.update(task_id=task_id, level=level, message=message, source=source, metadata=metadata) + + logger = TaskLogger(task_id="task-1", add_log_fn=add_log_fn, source="plugin") + logger.info("integration test") + + # Allow the scheduled coroutine to run + await asyncio.sleep(0.01) + assert received.get("task_id") == "task-1" + assert received.get("level") == "INFO" + assert received.get("message") == "integration test" + assert received.get("source") == "plugin" +# #endregion Test.TaskLogger diff --git a/backend/tests/test_core_timezone_edge.py b/backend/tests/test_core_timezone_edge.py new file mode 100644 index 00000000..d7ffd499 --- /dev/null +++ b/backend/tests/test_core_timezone_edge.py @@ -0,0 +1,89 @@ +# #region Test.AppTimezoneEdge [C:1] [TYPE Module] [SEMANTICS test,timezone,format,offset,edge] +# @BRIEF Edge-case coverage for core/timezone.py — format_timezone_offset fallback. + +from datetime import timezone, timedelta +from unittest.mock import patch +import pytest + + +class TestFormatTimezoneOffsetEdge: + """Cover line 90 in format_timezone_offset — fallback when strftime returns empty.""" + + def test_offset_returns_expected_format(self): + from src.core.timezone import format_timezone_offset, invalidate_timezone_cache + + invalidate_timezone_cache() + result = format_timezone_offset() + # Should return something like "+03:00" or "+05:00" + assert isinstance(result, str) + assert ":" in result + + def test_offset_with_zero_offset(self): + """Simulate UTC timezone offset returning "+00:00".""" + import src.core.timezone as tz_mod + + # Mock now() to return a UTC time + fake_dt = type("FakeDT", (), { + "strftime": lambda self, fmt: "+0000", + })() + + with patch.object(tz_mod, "now", return_value=fake_dt): + result = tz_mod.format_timezone_offset() + assert result == "+00:00" + + def test_offset_with_empty_strftime(self): + """When strftime returns empty, fallback to '+00:00'.""" + import src.core.timezone as tz_mod + + fake_dt = type("FakeDT", (), { + "strftime": lambda self, fmt: "", + })() + + with patch.object(tz_mod, "now", return_value=fake_dt): + result = tz_mod.format_timezone_offset() + assert result == "+00:00" + + def test_now_returns_aware(self): + from src.core.timezone import now + + dt = now() + assert dt.tzinfo is not None + + def test_localize_with_none(self): + from src.core.timezone import localize + + assert localize(None) is None + + def test_validate_timezone_with_invalid(self): + from src.core.timezone import validate_timezone + + assert validate_timezone("Not/A/Timezone") is False + + def test_validate_timezone_with_none(self): + from src.core.timezone import validate_timezone + + assert validate_timezone(None) is False # type: ignore + + def test_invalidate_and_reget(self): + import src.core.timezone as tz_mod + + tz_mod._APP_TZ_CACHE = None + tz_mod.invalidate_timezone_cache() + assert tz_mod._APP_TZ_CACHE is None + tz1 = tz_mod.get_app_timezone() + assert tz_mod._APP_TZ_CACHE is tz1 + tz_mod.invalidate_timezone_cache() + assert tz_mod._APP_TZ_CACHE is None + + def test_get_default_tz_name_env(self): + from src.core.timezone import _get_default_tz_name + + with patch("src.core.timezone.os.getenv", return_value="America/New_York"): + assert _get_default_tz_name() == "America/New_York" + + def test_get_default_tz_name_default(self): + from src.core.timezone import _get_default_tz_name + + with patch("src.core.timezone.os.getenv", return_value="Europe/Moscow"): + assert _get_default_tz_name() == "Europe/Moscow" +# #endregion Test.AppTimezoneEdge diff --git a/backend/tests/test_models_edge.py b/backend/tests/test_models_edge.py new file mode 100644 index 00000000..3912129b --- /dev/null +++ b/backend/tests/test_models_edge.py @@ -0,0 +1,106 @@ +# #region Test.Models.Edge [C:1] [TYPE Module] [SEMANTICS test,models,agent,api_key,edge] +# @BRIEF Edge-case coverage for model files — agent.py _uuid(), api_key.py __repr__. +# @RELATION BINDS_TO -> [Models.Agent] +# @RELATION BINDS_TO -> [APIKeyModel] + +import pytest + + +class TestAgentModelEdge: + """Cover _uuid() function in agent.py (line 14).""" + + def test_uuid_generates_string(self): + from src.models.agent import _uuid + + uid = _uuid() + assert isinstance(uid, str) + assert len(uid) > 20 # UUID4 is 36 chars + assert uid.count("-") == 4 # Standard UUID format + + def test_uuid_unique(self): + from src.models.agent import _uuid + + uids = {_uuid() for _ in range(100)} + assert len(uids) == 100 # All unique + + def test_agent_conversation_defaults(self): + """AgentConversation creates with defaults.""" + from src.models.agent import AgentConversation + + conv = AgentConversation(user_id="user-1") + assert conv.user_id == "user-1" + # id defaults to uuid4 string + assert isinstance(conv.id, str) or conv.id is None + + def test_agent_message_repr(self): + """AgentMessage creates with defaults.""" + from src.models.agent import AgentMessage + + msg = AgentMessage( + conversation_id="conv-1", + role="user", + text="Hello", + state="sent", + ) + assert msg.role == "user" + assert msg.text == "Hello" + assert msg.state == "sent" + + +class TestApiKeyModelEdge: + """Cover __repr__ in api_key.py (line 33).""" + + def test_repr(self): + from src.models.api_key import APIKey + + key = APIKey( + key_hash="a" * 64, + prefix="ssk_abc1234", + name="Test Key", + permissions=["maintenance:start"], + ) + rep = repr(key) + assert "ssk_abc1234" in rep + assert "Test Key" in rep + assert "APIKey" in rep + + def test_repr_in_db(self, clean_db): + from src.models.api_key import APIKey + + key = APIKey( + key_hash="b" * 64, + prefix="ssk_xyz7890", + name="DB Key", + permissions=[], + ) + clean_db.add(key) + clean_db.commit() + clean_db.refresh(key) + + rep = repr(key) + assert "ssk_xyz7890" in rep + assert "DB Key" in rep + + +# ── Shared fixture ─────────────────────────────────────────────── + + +@pytest.fixture +def clean_db(): + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from sqlalchemy.pool import StaticPool + + from src.models.mapping import Base + + engine = create_engine( + "sqlite:///:memory:", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + yield session + session.close() +# #endregion Test.Models.Edge diff --git a/backend/tests/test_schemas_edge.py b/backend/tests/test_schemas_edge.py new file mode 100644 index 00000000..f06b3223 --- /dev/null +++ b/backend/tests/test_schemas_edge.py @@ -0,0 +1,135 @@ +# #region Test.Schemas.Edge [C:1] [TYPE Module] [SEMANTICS test,schemas,auth,translate,edge] +# @BRIEF Edge-case coverage for schemas/auth.py and schemas/translate.py uncovered lines. + +import pytest +from pydantic import ValidationError + + +# ── auth.py: password validation ───────────────────────────────── + + +class TestUserCreatePasswordValidation: + """Password strength validator edge cases (line 151).""" + + def test_password_min_length_raises(self): + from src.schemas.auth import UserCreate + + with pytest.raises(ValidationError) as exc: + UserCreate( + username="test", + email="test@x.com", + password="Ab1", # too short (3 chars, min 8) + ) + errors = exc.value.errors() + assert any("at least 8" in str(e["msg"]) for e in errors) + + def test_password_missing_uppercase_raises(self): + from src.schemas.auth import UserCreate + + with pytest.raises(ValidationError) as exc: + UserCreate( + username="test", + email="test@x.com", + password="abcdefgh1", # no uppercase + ) + errors = exc.value.errors() + assert any("uppercase" in str(e["msg"]) for e in errors) + + def test_password_missing_lowercase_raises(self): + from src.schemas.auth import UserCreate + + with pytest.raises(ValidationError) as exc: + UserCreate( + username="test", + email="test@x.com", + password="ABCDEFGH1", # no lowercase + ) + errors = exc.value.errors() + assert any("lowercase" in str(e["msg"]) for e in errors) + + def test_password_missing_digit_raises(self): + from src.schemas.auth import UserCreate + + with pytest.raises(ValidationError) as exc: + UserCreate( + username="test", + email="test@x.com", + password="Abcdefgh", # no digit + ) + errors = exc.value.errors() + assert any("digit" in str(e["msg"]) for e in errors) + + def test_password_valid(self): + from src.schemas.auth import UserCreate + + user = UserCreate( + username="test", + email="test@x.com", + password="ValidPassword1", + ) + assert user.password == "ValidPassword1" + assert user.roles == [] + + +# ── translate.py: BCP-47 validation and pattern length ──────────── + + +class TestTranslateSchemasEdge: + """BCP-47 validation and pattern length validation.""" + + def test_bcp47_empty_list(self): + from src.schemas.translate import _validate_bcp47_list + + result = _validate_bcp47_list([]) + assert result == [] + + def test_bcp47_none_list(self): + from src.schemas.translate import _validate_bcp47_list + + result = _validate_bcp47_list(None) + assert result is None + + def test_bcp47_valid_tags(self): + from src.schemas.translate import _validate_bcp47_list + + result = _validate_bcp47_list(["en", "ru", "zh-CN", "pt-BR"]) + assert result == ["en", "ru", "zh-CN", "pt-BR"] + + def test_bcp47_invalid_tag_raises(self): + from src.schemas.translate import _validate_bcp47_list + + with pytest.raises(ValueError) as exc: + _validate_bcp47_list(["en", "invalid tag with spaces"]) + assert "BCP-47" in str(exc.value) + + def test_bcp47_empty_tag_raises(self): + from src.schemas.translate import _validate_bcp47_list + + with pytest.raises(ValueError) as exc: + _validate_bcp47_list(["en", ""]) + assert "BCP-47" in str(exc.value) + + def test_bulk_find_replace_pattern_too_long(self): + from src.schemas.translate import BulkFindReplaceRequest + + with pytest.raises(ValidationError) as exc: + BulkFindReplaceRequest( + find_pattern="x" * 501, + replacement_text="test", + target_language="en", + ) + errors = exc.value.errors() + assert any("500" in str(e["msg"]) for e in errors) + + def test_bulk_find_replace_pattern_valid(self): + from src.schemas.translate import BulkFindReplaceRequest + + req = BulkFindReplaceRequest( + find_pattern="old text", + replacement_text="new text", + target_language="en", + ) + assert req.find_pattern == "old text" + assert req.replacement_text == "new text" + assert req.submit_to_dictionary_with_context is False +# #endregion Test.Schemas.Edge