test: 6 parallel agents — +40 test files across core, agent, translate, services, API routes, dataset_review. core 100%, agent 100%, services 100%, translate plugin mostly done. Pending: ~10 minor failures to fix

This commit is contained in:
2026-06-15 16:45:49 +03:00
parent a20879fa37
commit c8e44a1b86
53 changed files with 9047 additions and 13 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ──

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ===

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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