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:
260
backend/tests/api/test_assistant_dataset_review_extended.py
Normal file
260
backend/tests/api/test_assistant_dataset_review_extended.py
Normal 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
|
||||
967
backend/tests/api/test_assistant_edge_cases.py
Normal file
967
backend/tests/api/test_assistant_edge_cases.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
198
backend/tests/api/test_profile_routes.py
Normal file
198
backend/tests/api/test_profile_routes.py
Normal 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
|
||||
193
backend/tests/api/test_reports_routes.py
Normal file
193
backend/tests/api/test_reports_routes.py
Normal 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
|
||||
161
backend/tests/api/test_translate_correction_routes.py
Normal file
161
backend/tests/api/test_translate_correction_routes.py
Normal 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
|
||||
126
backend/tests/api/test_translate_metrics_routes.py
Normal file
126
backend/tests/api/test_translate_metrics_routes.py
Normal 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
|
||||
165
backend/tests/api/test_translate_preview_routes.py
Normal file
165
backend/tests/api/test_translate_preview_routes.py
Normal 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
|
||||
213
backend/tests/api/test_translate_run_history_routes.py
Normal file
213
backend/tests/api/test_translate_run_history_routes.py
Normal 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
|
||||
@@ -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
|
||||
275
backend/tests/plugins/translate/test_metrics.py
Normal file
275
backend/tests/plugins/translate/test_metrics.py
Normal 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
|
||||
212
backend/tests/plugins/translate/test_orchestrator.py
Normal file
212
backend/tests/plugins/translate/test_orchestrator.py
Normal 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
|
||||
220
backend/tests/plugins/translate/test_orchestrator_aggregator.py
Normal file
220
backend/tests/plugins/translate/test_orchestrator_aggregator.py
Normal 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
|
||||
214
backend/tests/plugins/translate/test_orchestrator_exec.py
Normal file
214
backend/tests/plugins/translate/test_orchestrator_exec.py
Normal 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
|
||||
227
backend/tests/plugins/translate/test_orchestrator_planner.py
Normal file
227
backend/tests/plugins/translate/test_orchestrator_planner.py
Normal 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
|
||||
280
backend/tests/plugins/translate/test_orchestrator_query.py
Normal file
280
backend/tests/plugins/translate/test_orchestrator_query.py
Normal 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
|
||||
253
backend/tests/plugins/translate/test_orchestrator_retry.py
Normal file
253
backend/tests/plugins/translate/test_orchestrator_retry.py
Normal 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
|
||||
@@ -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
|
||||
86
backend/tests/plugins/translate/test_orchestrator_runner.py
Normal file
86
backend/tests/plugins/translate/test_orchestrator_runner.py
Normal 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
|
||||
340
backend/tests/plugins/translate/test_preview_prompt_builder.py
Normal file
340
backend/tests/plugins/translate/test_preview_prompt_builder.py
Normal 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
|
||||
135
backend/tests/plugins/translate/test_preview_token_estimator.py
Normal file
135
backend/tests/plugins/translate/test_preview_token_estimator.py
Normal 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
|
||||
160
backend/tests/services/clean_release/test_audit_service.py
Normal file
160
backend/tests/services/clean_release/test_audit_service.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
121
backend/tests/services/clean_release/test_report_builder.py
Normal file
121
backend/tests/services/clean_release/test_report_builder.py
Normal 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
|
||||
@@ -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 ===
|
||||
|
||||
|
||||
169
backend/tests/services/dataset_review/test_helpers_edge.py
Normal file
169
backend/tests/services/dataset_review/test_helpers_edge.py
Normal 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
|
||||
444
backend/tests/services/dataset_review/test_orchestrator_edge.py
Normal file
444
backend/tests/services/dataset_review/test_orchestrator_edge.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
29
backend/tests/services/test_services___init__.py
Normal file
29
backend/tests/services/test_services___init__.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
232
backend/tests/test_core/test_archive_parser.py
Normal file
232
backend/tests/test_core/test_archive_parser.py
Normal 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
|
||||
80
backend/tests/test_core/test_async_superset_client.py
Normal file
80
backend/tests/test_core/test_async_superset_client.py
Normal 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
|
||||
194
backend/tests/test_core/test_auth_repository.py
Normal file
194
backend/tests/test_core/test_auth_repository.py
Normal 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
|
||||
339
backend/tests/test_core/test_jwt.py
Normal file
339
backend/tests/test_core/test_jwt.py
Normal 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
|
||||
204
backend/tests/test_core/test_plugin_base.py
Normal file
204
backend/tests/test_core/test_plugin_base.py
Normal 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
|
||||
150
backend/tests/test_core/test_superset_charts.py
Normal file
150
backend/tests/test_core/test_superset_charts.py
Normal 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
|
||||
158
backend/tests/test_core/test_superset_user_projection.py
Normal file
158
backend/tests/test_core/test_superset_user_projection.py
Normal 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
|
||||
136
backend/tests/test_core/test_task_context.py
Normal file
136
backend/tests/test_core/test_task_context.py
Normal 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
|
||||
197
backend/tests/test_core/test_task_logger.py
Normal file
197
backend/tests/test_core/test_task_logger.py
Normal 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
|
||||
89
backend/tests/test_core_timezone_edge.py
Normal file
89
backend/tests/test_core_timezone_edge.py
Normal 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
|
||||
106
backend/tests/test_models_edge.py
Normal file
106
backend/tests/test_models_edge.py
Normal 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
|
||||
135
backend/tests/test_schemas_edge.py
Normal file
135
backend/tests/test_schemas_edge.py
Normal 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
|
||||
Reference in New Issue
Block a user