fix: 5 agents — core 703/703, settings 38/38, git edges 191/191, API routes 1139/1140, plugins +70 coverage

This commit is contained in:
2026-06-15 18:02:09 +03:00
parent 9cf2d6400a
commit 3de67c258a
18 changed files with 1432 additions and 154 deletions

View File

@@ -134,6 +134,11 @@ class TestCreateMapping:
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
# Simulate db.refresh setting the id
def _refresh(obj):
obj.id = "map-new-1"
mock_db.refresh.side_effect = _refresh
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/api/mappings", json=self.CREATE_PAYLOAD)
@@ -166,7 +171,7 @@ class TestSuggestMappingsApi:
mock_suggestions = [{"source": "db1", "target": "db2", "score": 0.95}]
from src.dependencies import get_config_manager
with patch("src.api.routes.mappings.MappingService") as MockMappingSvc:
with patch("src.services.mapping_service.MappingService") as MockMappingSvc:
instance = MagicMock()
instance.get_suggestions = AsyncMock(return_value=mock_suggestions)
MockMappingSvc.return_value = instance
@@ -180,7 +185,7 @@ class TestSuggestMappingsApi:
def test_error(self):
mock_config = MagicMock()
from src.dependencies import get_config_manager
with patch("src.api.routes.mappings.MappingService") as MockMappingSvc:
with patch("src.services.mapping_service.MappingService") as MockMappingSvc:
instance = MagicMock()
instance.get_suggestions = AsyncMock(side_effect=RuntimeError("suggestion failed"))
MockMappingSvc.return_value = instance

View File

@@ -65,7 +65,7 @@ class TestGetDashboards:
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_env()]
dashboards = [{"id": 1, "dashboard_title": "Test Dash"}]
dashboards = [{"id": 1, "title": "Test Dash", "last_modified": "2024-01-01T00:00:00", "status": "published"}]
mock_client = MagicMock()
mock_client.get_dashboards_summary = AsyncMock(return_value=dashboards)
@@ -300,10 +300,15 @@ class TestGetResourceMappings:
mock_db.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 2
def _mock_resource_type(value: str):
m = MagicMock()
m.value = value
return m
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [
MagicMock(id=1, environment_id="env-1", resource_type="DATABASE", uuid="u1",
MagicMock(id=1, environment_id="env-1", resource_type=_mock_resource_type("database"), uuid="u1",
remote_integer_id=100, resource_name="DB", last_synced_at=None),
MagicMock(id=2, environment_id="env-1", resource_type="DATABASE", uuid="u2",
MagicMock(id=2, environment_id="env-1", resource_type=_mock_resource_type("database"), uuid="u2",
remote_integer_id=101, resource_name="DB2", last_synced_at=None),
]
@@ -368,7 +373,7 @@ class TestTriggerSyncNow:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.post("/migration/sync-now")
resp = client.post("/api/migration/sync-now")
assert resp.status_code == 400
def test_sync_with_failures(self):

View File

@@ -105,7 +105,7 @@ class TestGetSettings:
from src.core.config_models import Environment
env = Environment(
id="env-1", name="Test Env", url="https://example.com",
username="admin", password="real-password", stage="dev",
username="admin", password="real-password", stage="DEV",
)
real_config = _make_real_config(environments=[env])
@@ -245,7 +245,7 @@ class TestGetEnvironments:
mock_config = MagicMock()
real_env = Environment(
id="env-1", name="Test Env", url="https://example.com",
username="admin", password="secret", stage="dev",
username="admin", password="secret", stage="DEV",
)
mock_config.get_environments.return_value = [real_env]
@@ -267,7 +267,9 @@ class TestAddEnvironment:
"id": "env-new",
"name": "New Env",
"url": "https://superset.example.com",
"username": "admin",
"password": "secret",
"stage": "DEV",
}
def test_success(self):
@@ -308,7 +310,9 @@ class TestUpdateEnvironment:
"id": "env-1",
"name": "Updated Env",
"url": "https://superset.example.com",
"username": "admin",
"password": "new-password",
"stage": "DEV",
}
def test_success(self):
@@ -344,7 +348,8 @@ class TestUpdateEnvironment:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.put("/api/settings/environments/env-1", json={
"id": "env-1", "name": "Updated", "url": "https://x.com", "password": "********",
"id": "env-1", "name": "Updated", "url": "https://x.com",
"username": "admin", "password": "********", "stage": "DEV",
})
assert resp.status_code == 200
@@ -365,6 +370,7 @@ class TestUpdateEnvironment:
def test_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_env("env-1")]
mock_config.update_environment.return_value = False
with patch("src.api.routes.settings.AsyncSupersetClient") as mock_client_cls:
mock_client = MagicMock()
@@ -494,8 +500,26 @@ class TestGetValidationPolicies:
"""GET /settings/automation/policies"""
def test_success(self):
from datetime import time, datetime, timezone
from src.models.llm import ValidationPolicy
mock_db = MagicMock()
mock_db.query.return_value.all.return_value = [MagicMock(id="p1")]
mock_db.query.return_value.all.return_value = [
ValidationPolicy(
id="p1",
name="Test Policy",
environment_id="env-1",
is_active=True,
notify_owners=True,
alert_condition="FAIL_ONLY",
dashboard_ids=["d1"],
schedule_days=[1, 3, 5],
window_start=time(9, 0),
window_end=time(18, 0),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
]
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
@@ -509,12 +533,26 @@ class TestCreateValidationPolicy:
"""POST /settings/automation/policies"""
def test_success(self):
from datetime import datetime, timezone
mock_db = MagicMock()
now = datetime.now(timezone.utc)
# Simulate that db.refresh populates auto-generated fields (id, created_at, updated_at)
def _refresh(obj):
obj.id = getattr(obj, "id", None) or "new-id"
if getattr(obj, "created_at", None) is None:
obj.created_at = now
if getattr(obj, "updated_at", None) is None:
obj.updated_at = now
mock_db.refresh.side_effect = _refresh
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/api/settings/automation/policies", json={
"name": "Test Policy", "environment_id": "env-1", "dashboard_ids": [],
"schedule_days": [1, 3, 5], "window_start": "09:00", "window_end": "18:00",
})
assert resp.status_code == 200
mock_db.add.assert_called_once()
@@ -527,8 +565,18 @@ class TestUpdateValidationPolicy:
"""PATCH /settings/automation/policies/{id}"""
def test_success(self):
from datetime import time, datetime, timezone
from src.models.llm import ValidationPolicy
mock_db = MagicMock()
mock_policy = MagicMock()
mock_policy = ValidationPolicy(
id="p1", name="Test", environment_id="env-1",
is_active=True, notify_owners=True, alert_condition="FAIL_ONLY",
dashboard_ids=[], schedule_days=[1],
window_start=time(9, 0), window_end=time(18, 0),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
mock_db.query.return_value.filter.return_value.first.return_value = mock_policy
from src.core.database import get_db
@@ -612,10 +660,13 @@ class TestListConnections:
mock_conn_svc = MagicMock()
mock_conn_svc.list_connections.return_value = [{"id": "conn-1", "name": "Test"}]
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.get("/api/settings/connections")
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.get("/api/settings/connections")
assert resp.status_code == 200
@@ -631,10 +682,13 @@ class TestGetConnection:
mock_conn_svc.get_connection.return_value = mock_conn
mock_conn_svc._count_job_references.return_value = 2
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.get("/api/settings/connections/conn-1")
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.get("/api/settings/connections/conn-1")
assert resp.status_code == 200
data = resp.json()
assert data["password"] == "********"
@@ -644,10 +698,13 @@ class TestGetConnection:
mock_conn_svc = MagicMock()
mock_conn_svc.get_connection.return_value = None
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.get("/api/settings/connections/unknown")
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.get("/api/settings/connections/unknown")
assert resp.status_code == 404
@@ -660,23 +717,29 @@ class TestCreateConnection:
mock_conn_svc = MagicMock()
mock_conn_svc.create_connection.return_value = {"id": "conn-new", "name": "New"}
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.post("/api/settings/connections", json={
"name": "New", "host": "localhost", "port": 5432,
"database": "db", "username": "u", "password": "p", "dialect": "postgresql",
})
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.post("/api/settings/connections", json={
"name": "New", "host": "localhost", "port": 5432,
"database": "db", "username": "u", "password": "p", "dialect": "postgresql",
})
assert resp.status_code == 201
def test_value_error(self):
mock_conn_svc = MagicMock()
mock_conn_svc.create_connection.side_effect = ValueError("Invalid")
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.post("/api/settings/connections", json={"name": "Bad"})
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.post("/api/settings/connections", json={"name": "Bad"})
assert resp.status_code == 422
@@ -689,20 +752,26 @@ class TestUpdateConnection:
mock_conn_svc = MagicMock()
mock_conn_svc.update_connection.return_value = {"id": "conn-1", "name": "Updated"}
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.put("/api/settings/connections/conn-1", json={"name": "Updated"})
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.put("/api/settings/connections/conn-1", json={"name": "Updated"})
assert resp.status_code == 200
def test_value_error(self):
mock_conn_svc = MagicMock()
mock_conn_svc.update_connection.side_effect = ValueError("Invalid")
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.put("/api/settings/connections/conn-1", json={"name": ""})
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.put("/api/settings/connections/conn-1", json={"name": ""})
assert resp.status_code == 422
@@ -715,10 +784,13 @@ class TestDeleteConnection:
mock_conn_svc = MagicMock()
mock_conn_svc.delete_connection.return_value = {"success": True}
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.delete("/api/settings/connections/conn-1")
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.delete("/api/settings/connections/conn-1")
assert resp.status_code == 200
def test_in_use(self):
@@ -727,10 +799,13 @@ class TestDeleteConnection:
"success": False, "blocking_jobs": ["job-1"],
}
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.delete("/api/settings/connections/conn-1")
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.delete("/api/settings/connections/conn-1")
assert resp.status_code == 409
@@ -743,9 +818,12 @@ class TestTestConnectionEndpoint:
mock_conn_svc = MagicMock()
mock_conn_svc.test_connection = AsyncMock(return_value={"success": True})
from src.api.routes.settings import _get_connection_service
from src.dependencies import get_config_manager
with patch("src.api.routes.settings._get_connection_service", return_value=mock_conn_svc):
client = _make_client({get_config_manager: lambda: MagicMock()})
resp = client.post("/api/settings/connections/conn-1/test")
client = _make_client({
get_config_manager: lambda: MagicMock(),
_get_connection_service: lambda: mock_conn_svc,
})
resp = client.post("/api/settings/connections/conn-1/test")
assert resp.status_code == 200
# #endregion Test.Api.Settings

View File

@@ -56,7 +56,10 @@ class TestListFiles:
def test_success(self):
client, mock_loader = _make_client()
mock_plugin = MagicMock()
mock_plugin.list_files.return_value = [{"name": "file1.txt"}]
mock_plugin.list_files.return_value = [{
"name": "file1.txt", "path": "/files/file1.txt", "size": 1024,
"created_at": "2024-01-01T00:00:00", "category": "backups", "mime_type": "text/plain",
}]
mock_loader.get_plugin.return_value = mock_plugin
resp = client.get(f"{P}/files")
assert resp.status_code == 200
@@ -66,7 +69,7 @@ class TestListFiles:
mock_plugin = MagicMock()
mock_plugin.list_files.return_value = []
mock_loader.get_plugin.return_value = mock_plugin
resp = client.get(f"{P}/files?category=BACKUPS&path=/test&recursive=true")
resp = client.get(f"{P}/files?category=backups&path=/test&recursive=true")
assert resp.status_code == 200
mock_plugin.list_files.assert_called_once_with("backups", "/test", True)
@@ -83,7 +86,10 @@ class TestUploadFile:
def test_success(self):
client, mock_loader = _make_client()
mock_plugin = MagicMock()
mock_plugin.save_file = AsyncMock(return_value={"name": "test.txt"})
mock_plugin.save_file = AsyncMock(return_value={
"name": "test.txt", "path": "/uploads/test.txt", "size": 5,
"created_at": "2024-01-01T00:00:00", "category": "backups", "mime_type": "text/plain",
})
mock_loader.get_plugin.return_value = mock_plugin
resp = client.post(f"{P}/upload", data={"category": "backups"}, files={"file": ("test.txt", b"hello")})
assert resp.status_code == 201
@@ -110,7 +116,7 @@ class TestDeleteFile:
client, mock_loader = _make_client()
mock_plugin = MagicMock()
mock_loader.get_plugin.return_value = mock_plugin
resp = client.delete(f"{P}/files/BACKUPS/test.txt")
resp = client.delete(f"{P}/files/backups/test.txt")
assert resp.status_code == 204
def test_not_found(self):
@@ -118,7 +124,7 @@ class TestDeleteFile:
mock_plugin = MagicMock()
mock_plugin.delete_file.side_effect = FileNotFoundError
mock_loader.get_plugin.return_value = mock_plugin
resp = client.delete(f"{P}/files/BACKUPS/missing.txt")
resp = client.delete(f"{P}/files/backups/missing.txt")
assert resp.status_code == 404
def test_value_error(self):
@@ -126,13 +132,13 @@ class TestDeleteFile:
mock_plugin = MagicMock()
mock_plugin.delete_file.side_effect = ValueError("bad")
mock_loader.get_plugin.return_value = mock_plugin
resp = client.delete(f"{P}/files/BACKUPS/bad.txt")
resp = client.delete(f"{P}/files/backups/bad.txt")
assert resp.status_code == 400
def test_plugin_not_loaded(self):
client, mock_loader = _make_client()
mock_loader.get_plugin.return_value = None
resp = client.delete(f"{P}/files/BACKUPS/test.txt")
resp = client.delete(f"{P}/files/backups/test.txt")
assert resp.status_code == 500
@@ -144,7 +150,7 @@ class TestDownloadFile:
mock_plugin = MagicMock()
mock_plugin.get_file_path.side_effect = FileNotFoundError
mock_loader.get_plugin.return_value = mock_plugin
resp = client.get(f"{P}/download/BACKUPS/missing.txt")
resp = client.get(f"{P}/download/backups/missing.txt")
assert resp.status_code == 404
def test_value_error(self):
@@ -152,13 +158,13 @@ class TestDownloadFile:
mock_plugin = MagicMock()
mock_plugin.get_file_path.side_effect = ValueError("bad")
mock_loader.get_plugin.return_value = mock_plugin
resp = client.get(f"{P}/download/BACKUPS/bad.txt")
resp = client.get(f"{P}/download/backups/bad.txt")
assert resp.status_code == 400
def test_plugin_not_loaded(self):
client, mock_loader = _make_client()
mock_loader.get_plugin.return_value = None
resp = client.get(f"{P}/download/BACKUPS/test.txt")
resp = client.get(f"{P}/download/backups/test.txt")
assert resp.status_code == 500

View File

@@ -350,6 +350,6 @@ class TestClearTasks:
from src.dependencies import get_task_manager
client = _make_client({get_task_manager: lambda: mock_tm})
resp = client.delete("/api/tasks?status=COMPLETED")
resp = client.delete("/api/tasks?status=SUCCESS")
assert resp.status_code == 204
# #endregion Test.Api.Tasks

View File

@@ -0,0 +1,207 @@
# #region Test.LLMParse [C:3] [TYPE Module] [SEMANTICS test, translate, llm, parse, json, recovery]
# @BRIEF Tests for _llm_parse.py — parse_llm_response, markdown recovery, truncated row recovery.
# @RELATION BINDS_TO -> [_llm_parse]
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import json
import pytest
from src.plugins.translate._llm_parse import (
parse_llm_response,
_recover_from_markdown,
_recover_truncated_rows,
)
class TestRecoverFromMarkdown:
"""_recover_from_markdown — extract JSON from markdown code blocks."""
def test_json_code_block(self):
"""Standard ```json code block."""
text = "```json\n{\"rows\": [{\"row_id\": 1, \"translation\": \"hola\"}]}\n```"
result = _recover_from_markdown(text)
assert result is not None
assert result["rows"][0]["row_id"] == 1
def test_no_marker_code_block(self):
"""Code block without json marker."""
text = "```\n{\"rows\": [{\"row_id\": 1}]}\n```"
result = _recover_from_markdown(text)
assert result is not None
assert len(result["rows"]) == 1
def test_no_code_block(self):
"""Plain text without code block."""
result = _recover_from_markdown("just plain text")
assert result is None
def test_invalid_json_in_block(self):
"""Code block with invalid JSON returns None."""
text = "```json\n{invalid json}\n```"
result = _recover_from_markdown(text)
assert result is None
def test_empty_block(self):
"""Empty code block returns None."""
text = "```json\n```"
result = _recover_from_markdown(text)
assert result is None
def test_inline_text_before_after(self):
"""Text before and after code block still extracts JSON."""
text = "some text\n```json\n{\"rows\": []}\n```\nmore text"
result = _recover_from_markdown(text)
assert result is not None
class TestRecoverTruncatedRows:
"""_recover_truncated_rows — extract complete rows from truncated JSON."""
def test_recover_single_row(self):
"""Single complete row extracted from truncated text."""
text = 'some text {"row_id": 1, "translation": "hola"} more text'
result = _recover_truncated_rows(text, 1, "length")
assert result is not None
assert len(result["rows"]) == 1
def test_recover_multiple_rows(self):
"""Multiple rows extracted."""
text = '{"row_id": 1, "translation": "hola"} {"row_id": 2, "translation": "adios"}'
result = _recover_truncated_rows(text, 2, "length")
assert result is not None
assert len(result["rows"]) == 2
def test_no_rows_found(self):
"""No row-like patterns found."""
result = _recover_truncated_rows("completely invalid text", 1, "length")
assert result is None
def test_invalid_row_patterns_skipped(self):
"""Invalid JSON within row-like patterns is skipped."""
text = '{"row_id": 1, invalid} {"row_id": 2, "translation": "ok"}'
result = _recover_truncated_rows(text, 1, "length")
assert result is not None
assert len(result["rows"]) == 1
def test_empty_text(self):
"""Empty text returns None."""
result = _recover_truncated_rows("", 1, "length")
assert result is None
def test_no_finish_reason(self):
"""Works without finish_reason."""
text = '{"row_id": 1, "translation": "hola"}'
result = _recover_truncated_rows(text, 1, None)
assert result is not None
class TestParseLLMResponse:
"""parse_llm_response — full LLM response parsing pipeline."""
def test_valid_json_direct(self):
"""Direct valid JSON response."""
response = json.dumps({
"rows": [
{"row_id": 1, "ru": "привет", "detected_source_language": "en"},
{"row_id": 2, "ru": "мир", "detected_source_language": "en"},
]
})
result = parse_llm_response(response, expected_count=2, target_languages=["ru"])
assert len(result) == 2
assert result["1"]["ru"] == "привет"
assert result["2"]["ru"] == "мир"
def test_missing_rows_key(self):
"""No 'rows' key returns empty dict (defaults to empty list)."""
result = parse_llm_response("{}", expected_count=1)
assert result == {}
def test_rows_not_a_list(self):
"""Rows key is not a list raises ValueError."""
with pytest.raises(ValueError, match="rows"):
parse_llm_response('{"rows": "not_a_list"}', expected_count=1)
def test_markdown_code_block(self):
"""Response wrapped in markdown code block."""
text = "```json\n{\"rows\": [{\"row_id\": 1, \"ru\": \"привет\"}]}\n```"
result = parse_llm_response(text, expected_count=1, target_languages=["ru"])
assert len(result) == 1
assert result["1"]["ru"] == "привет"
def test_truncated_recovery(self):
"""Truncated JSON recovers complete rows."""
text = '{"row_id": 1, "ru": "привет"} {"row_id": 2, "ru": "мир"'
result = parse_llm_response(text, expected_count=2, target_languages=["ru"])
assert len(result) >= 1
def test_invalid_json_fallback_to_markdown(self):
"""Invalid JSON falls through to markdown recovery."""
text = "some text\n```json\n{\"rows\": [{\"row_id\": 1, \"ru\": \"hola\"}]}\n```"
result = parse_llm_response(text, expected_count=1, target_languages=["ru"])
assert len(result) == 1
def test_row_without_id_skipped(self):
"""Row without row_id is skipped."""
response = json.dumps({
"rows": [
{"translation": "no_id"},
{"row_id": 2, "ru": "val", "detected_source_language": "en"},
]
})
result = parse_llm_response(response, expected_count=2, target_languages=["ru"])
assert len(result) == 1
assert "2" in result
def test_no_language_data_falls_back_to_translation(self):
"""Row without language key falls back to 'translation' key."""
response = json.dumps({
"rows": [
{"row_id": 1, "translation": "hola", "detected_source_language": "en"},
]
})
result = parse_llm_response(response, expected_count=1, target_languages=["ru"])
assert result["1"]["translation"] == "hola"
def test_no_language_or_translation_skipped(self):
"""Row with neither language nor translation is skipped."""
response = json.dumps({
"rows": [
{"row_id": 1, "detected_source_language": "en"},
]
})
result = parse_llm_response(response, expected_count=1, target_languages=["ru"])
assert len(result) == 0
def test_detected_language_defaults(self):
"""Missing detected_source_language defaults to 'und'."""
response = json.dumps({
"rows": [
{"row_id": 1, "ru": "привет"},
]
})
result = parse_llm_response(response, expected_count=1, target_languages=["ru"])
assert result["1"]["detected_source_language"] == "und"
def test_fully_failed_parse_raises(self):
"""Completely unparseable response raises ValueError."""
with pytest.raises(ValueError, match="not valid JSON"):
parse_llm_response("!@#$%^&*", expected_count=1)
def test_empty_target_languages(self):
"""Empty target_languages uses 'translation' fallback."""
response = json.dumps({
"rows": [
{"row_id": 1, "translation": "hola", "detected_source_language": "en"},
]
})
result = parse_llm_response(response, expected_count=1, target_languages=[])
assert result["1"]["translation"] == "hola"
def test_empty_rows(self):
"""Empty rows array returns empty dict."""
response = json.dumps({"rows": []})
result = parse_llm_response(response, expected_count=1)
assert result == {}

View File

@@ -0,0 +1,94 @@
# #region Test.PreviewResponseParser [C:3] [TYPE Module] [SEMANTICS test, translate, response, parser, data, extract]
# @BRIEF Tests for preview_response_parser.py — extract_data_rows.
# @RELATION BINDS_TO -> [preview_response_parser]
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from src.plugins.translate.preview_response_parser import extract_data_rows
class TestExtractDataRows:
"""extract_data_rows — extract data rows from Superset chart data API response."""
def test_result_list_with_data(self):
"""result is a list with item containing data key."""
response = {
"result": [
{"data": [{"col1": "val1"}, {"col1": "val2"}]}
]
}
result = extract_data_rows(response)
assert len(result) == 2
assert result[0]["col1"] == "val1"
def test_result_dict_with_data(self):
"""result is a dict with data key."""
response = {
"result": {"data": [{"col1": "val1"}]}
}
result = extract_data_rows(response)
assert len(result) == 1
def test_fallback_to_response_data(self):
"""Fallback to response.data when result has no data."""
response = {
"data": [{"col1": "val1"}]
}
result = extract_data_rows(response)
assert len(result) == 1
def test_result_list_returned_directly(self):
"""When result is a list with no data key, return it."""
response = {
"result": [{"col1": "val1"}, {"col1": "val2"}]
}
result = extract_data_rows(response)
assert len(result) == 2
def test_empty_result_list(self):
"""Empty result list returns empty list."""
response = {"result": []}
result = extract_data_rows(response)
assert result == []
def test_empty_data_list(self):
"""Empty data list falls back to returning result list."""
response = {
"result": [{"data": []}]
}
result = extract_data_rows(response)
# Returns the result list as fallback
assert result == [{"data": []}]
def test_no_result_or_data_key(self):
"""No result or data key returns empty list."""
response = {"other": "value"}
result = extract_data_rows(response)
assert result == []
def test_result_not_list_or_dict(self):
"""Result is neither list nor dict."""
response = {"result": "string_value"}
result = extract_data_rows(response)
assert result == []
def test_result_list_item_no_data(self):
"""Result list item has no data key, falls back to result list."""
response = {
"result": [{"other": "value"}]
}
result = extract_data_rows(response)
# Returns the result list as fallback
assert result == [{"other": "value"}]
def test_result_dict_data_empty_list(self):
"""Result dict data is empty list."""
response = {
"result": {"data": []}
}
result = extract_data_rows(response)
assert result == []

View File

@@ -0,0 +1,290 @@
# #region Test.TokenBudget [C:3] [TYPE Module] [SEMANTICS test, translate, token, budget, estimation]
# @BRIEF Tests for _token_budget.py — token estimation, batch sizing, output-aware constraints.
# @RELATION BINDS_TO -> [_token_budget]
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from src.plugins.translate._token_budget import (
_estimate_tokens_for_text,
_count_rows_that_fit,
_calculate_output_tokens,
_compute_max_rows_by_output,
_apply_output_aware_batch_sizing,
_build_warning,
estimate_token_budget,
)
class TestEstimateTokensForText:
"""_estimate_tokens_for_text — CJK-aware heuristic token counting."""
def test_empty_string(self):
"""Empty text returns 1."""
assert _estimate_tokens_for_text("") == 1
def test_ascii_only(self):
"""Pure ASCII text ~1.8 chars/token."""
result = _estimate_tokens_for_text("hello world")
assert result >= 1
assert isinstance(result, int)
def test_cjk_only(self):
"""Pure CJK text ~1.0 chars/token."""
result = _estimate_tokens_for_text("你好世界")
assert result >= 1
def test_mixed_text(self):
"""Mixed CJK + ASCII."""
result = _estimate_tokens_for_text("hello 你好 world 世界")
assert result >= 1
def test_cjk_ranges(self):
"""CJK includes Unicode ranges. Fullwidth forms."""
result = _estimate_tokens_for_text("\uff01\uff02")
assert result >= 1
def test_long_text(self):
"""Long text estimate is proportional."""
short = _estimate_tokens_for_text("hello")
long_ = _estimate_tokens_for_text("hello " * 100)
assert long_ > short
class TestCountRowsThatFit:
"""_count_rows_that_fit — consecutive rows within budget."""
def test_all_rows_fit(self):
"""All rows fit within budget."""
safe, total = _count_rows_that_fit([10, 20, 30], 5000)
assert safe == 3
assert total == 60
def test_partial_fit(self):
"""Only first 2 rows fit."""
safe, total = _count_rows_that_fit([10, 20, 5000], 2100)
assert safe == 2
assert total == 30
def test_no_rows_fit(self):
"""First row exceeds budget."""
safe, total = _count_rows_that_fit([5000], 1000)
assert safe == 0
assert total == 0
def test_empty_input(self):
"""Empty list returns (0, 0)."""
safe, total = _count_rows_that_fit([], 5000)
assert safe == 0
assert total == 0
def test_single_row_just_fits(self):
"""Single row just fits (with reasoning overhead)."""
budget = 200 + 2001 # row tokens + REASONING_OVERHEAD + 1
safe, total = _count_rows_that_fit([200], budget)
assert safe == 1
assert total == 200
class TestCalculateOutputTokens:
"""_calculate_output_tokens — output budget estimation."""
def test_basic(self):
"""Basic calculation returns positive int."""
result = _calculate_output_tokens(
safe_size=5, num_languages=2
)
assert result >= 1
assert isinstance(result, int)
def test_zero_safe_size(self):
"""Zero safe_size returns only overhead."""
result = _calculate_output_tokens(safe_size=0, num_languages=2)
assert result >= 2000 # REASONING_OVERHEAD
def test_large_batch(self):
"""Large batch produces larger estimate."""
small = _calculate_output_tokens(1, 1)
large = _calculate_output_tokens(50, 5)
assert large > small
class TestComputeMaxRowsByOutput:
"""_compute_max_rows_by_output — output-constrained row limit."""
def test_basic(self):
"""Positive result for normal params."""
result = _compute_max_rows_by_output(max_output_tokens=16384, num_languages=2)
assert result >= 1
def test_small_output_budget(self):
"""Tiny output budget returns 1."""
result = _compute_max_rows_by_output(max_output_tokens=100, num_languages=2)
assert result >= 1
def test_zero_per_row(self):
"""Edge: zero per_row returns fallback 20."""
result = _compute_max_rows_by_output(max_output_tokens=16384, num_languages=0)
assert result >= 1
class TestApplyOutputAwareBatchSizing:
"""_apply_output_aware_batch_sizing — reduce batch until output fits."""
def test_no_reduction_needed(self):
"""Batch fits without reduction."""
result = _apply_output_aware_batch_sizing(
safe_size=10, num_languages=1, max_output_tokens=50000
)
assert result == 10
def test_full_reduction(self):
"""Batch reduced when output exceeds budget."""
result = _apply_output_aware_batch_sizing(
safe_size=10, num_languages=3, max_output_tokens=2000
)
assert result < 10
def test_zero_size(self):
"""Zero safe_size returns 0."""
result = _apply_output_aware_batch_sizing(
safe_size=0, num_languages=1, max_output_tokens=50000
)
assert result == 0
class TestBuildWarning:
"""_build_warning — warning message generation."""
def test_no_warning_when_size_ok(self):
"""No warning when batch size matches safe size."""
assert _build_warning(10, 10, 20, 64000, 1000, 500, None) is None
def test_reduced_batch_warning(self):
"""Warning when safe_size < requested batch_size."""
w = _build_warning(10, 5, 20, 64000, 1000, 500, None)
assert w is not None
assert "Reduced" in w
def test_auto_calc_warning(self):
"""Warning when auto-calculated batch smaller than total rows."""
w = _build_warning(None, 5, 20, 64000, 1000, 500, None)
assert w is not None
assert "Auto-calculated" in w
def test_dict_warning_appended(self):
"""Dictionary warning is appended."""
w = _build_warning(10, 5, 20, 64000, 1000, 500, "Dict entries capped")
assert w is not None
assert "Dict entries capped" in w
def test_dict_warning_only(self):
"""Dictionary warning alone when no batch reduction."""
w = _build_warning(None, 20, 20, 64000, 1000, 500, "Dict capped")
assert w == "Dict capped"
class TestEstimateTokenBudget:
"""estimate_token_budget — main budget estimation entry point."""
def test_empty_source_rows(self):
"""Empty source rows returns sensible defaults."""
result = estimate_token_budget(
source_rows=[],
target_languages=["ru"],
)
assert result["batch_size_adjusted"] >= 1
assert "estimated_input_tokens" in result
assert "estimated_output_tokens" in result
assert "max_output_needed" in result
def test_single_row_single_lang(self):
"""Single row, single language."""
result = estimate_token_budget(
source_rows=[{"source_text": "hello world"}],
target_languages=["ru"],
)
assert result["batch_size_adjusted"] >= 1
def test_multiple_rows(self):
"""Multiple rows produce batch estimate."""
rows = [{"source_text": f"row {i}"} for i in range(5)]
result = estimate_token_budget(
source_rows=rows,
target_languages=["ru", "de"],
max_output_tokens=32000,
)
assert result["batch_size_adjusted"] >= 1
def test_with_context_columns(self):
"""Context columns add to token estimate."""
rows = [{"source_text": "hello", "description": "a long description here"}]
result = estimate_token_budget(
source_rows=rows,
target_languages=["ru"],
source_column="source_text",
context_columns=["description"],
)
assert result["batch_size_adjusted"] >= 1
def test_with_dictionary_entries(self):
"""Dictionary entries add tokens."""
rows = [{"source_text": "hello"}]
result = estimate_token_budget(
source_rows=rows,
target_languages=["ru"],
dictionary_entries=[{"id": 1}, {"id": 2}],
)
assert result["batch_size_adjusted"] >= 1
def test_with_provider_info(self):
"""Provider info resolves context window."""
rows = [{"source_text": "hello"}]
result = estimate_token_budget(
source_rows=rows,
target_languages=["ru"],
provider_info="gpt-4o-mini",
)
assert result["batch_size_adjusted"] >= 1
def test_explicit_context_window(self):
"""Explicit context_window overrides provider default."""
rows = [{"source_text": "hello"}]
result = estimate_token_budget(
source_rows=rows,
target_languages=["ru"],
context_window=128000,
max_output_tokens=4096,
)
assert result["batch_size_adjusted"] >= 1
assert result["available_input_budget"] == 128000 - 4096
def test_large_batch_reduced(self):
"""Batch size reduced when too large."""
many_rows = [{"source_text": "x" * 5000} for _ in range(100)]
result = estimate_token_budget(
source_rows=many_rows,
target_languages=["ru", "de", "fr"],
batch_size=50,
)
assert result["batch_size_adjusted"] >= 1
def test_no_target_languages_defaults(self):
"""Empty target_languages defaults to ['en']."""
result = estimate_token_budget(
source_rows=[{"source_text": "hello"}],
target_languages=[],
)
assert result["batch_size_adjusted"] >= 1
def test_provider_not_found_fallback(self):
"""Unknown provider falls back to defaults."""
result = estimate_token_budget(
source_rows=[{"source_text": "hello"}],
target_languages=["ru"],
provider_info="unknown-model-42",
)
assert result["batch_size_adjusted"] >= 1

View File

@@ -6,11 +6,13 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import contextlib
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from git.exc import InvalidGitRepositoryError
from src.services.git._base import GitServiceBase
@@ -137,7 +139,7 @@ class TestDeleteRepoEdge:
patch("os.path.exists", return_value=True), \
patch("os.path.isdir", return_value=False), \
patch("os.remove", new_callable=MagicMock) as mock_remove, \
patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()):
patch.object(svc, '_locked', return_value=contextlib.nullcontext()):
import src.services.git._base as gb_mod
orig_sl = gb_mod.SessionLocal
@@ -145,8 +147,9 @@ class TestDeleteRepoEdge:
mock_db = MagicMock()
gb_mod.SessionLocal = MagicMock(return_value=mock_db)
mock_db.query.return_value.filter.return_value.first.return_value = None
with pytest.raises(HTTPException, match="404"):
await svc.delete_repo(1)
# Source returns early when removed_files=True and no DB record
result = await svc.delete_repo(1)
assert result is None
mock_remove.assert_called_once_with("/tmp/repo-file")
finally:
gb_mod.SessionLocal = orig_sl
@@ -161,7 +164,7 @@ class TestDeleteRepoEdge:
with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
patch("os.path.exists", return_value=True), \
patch("os.path.isdir", return_value=True), \
patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()):
patch.object(svc, '_locked', return_value=contextlib.nullcontext()):
import src.services.git._base as gb_mod
orig_sl = gb_mod.SessionLocal
@@ -218,10 +221,10 @@ class TestInitRepoEdge:
patch("src.services.git._base.run_blocking") as mock_blocking, \
patch.object(svc, "_clone_with_auth", new_callable=AsyncMock) as mock_clone, \
patch.object(svc, "_ensure_gitflow_branches", create=True), \
patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()):
patch.object(svc, '_locked', return_value=contextlib.nullcontext()):
# First call (Repo(repo_path)) raises InvalidGitRepositoryError
mock_blocking.side_effect = [Exception("InvalidGitRepositoryError"), None, None]
# First run_blocking call is Path(repo_path).parent.mkdir, then Repo() raises
mock_blocking.side_effect = [None, InvalidGitRepositoryError("Invalid repo"), None, MagicMock()]
mock_clone.return_value = MagicMock()
result = await svc.init_repo(1, "https://remote.com/repo.git", "pat")
assert result is not None
@@ -237,11 +240,12 @@ class TestInitRepoEdge:
patch("src.services.git._base.run_blocking") as mock_blocking, \
patch.object(svc, "_clone_with_auth", new_callable=AsyncMock) as mock_clone, \
patch.object(svc, "_ensure_gitflow_branches", create=True), \
patch.object(svc, '_locked', return_value=(lambda g=None: (yield))()), \
patch.object(svc, '_locked', return_value=contextlib.nullcontext()), \
patch("pathlib.Path.exists", return_value=True):
mock_blocking.side_effect = [
Exception("InvalidGitRepositoryError"), # Repo() call
None, # Path(repo_path).parent.mkdir
InvalidGitRepositoryError("Invalid repo"), # Repo() call
None, # shutil.rmtree
None, # stale_path.unlink
MagicMock(), # Repo from clone

View File

@@ -6,7 +6,8 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
from unittest.mock import MagicMock, patch, PropertyMock
import contextlib
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
from fastapi import HTTPException
@@ -19,7 +20,7 @@ from git.objects.blob import Blob
def mixin():
from src.services.git._merge import GitServiceMergeMixin
m = GitServiceMergeMixin()
m._locked = lambda x: (lambda g=None: (yield))() # no-op context manager
m._locked = lambda x: contextlib.nullcontext() # no-op context manager
return m
@@ -62,7 +63,7 @@ class TestGetMergeStatus:
repo.working_tree_dir = "/tmp/repo"
repo.active_branch.name = "main"
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=False):
result = await mixin.get_merge_status(1)
assert result["has_unfinished_merge"] is False
@@ -74,7 +75,7 @@ class TestGetMergeStatus:
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=True), \
patch.object(mixin, '_build_unfinished_merge_payload', return_value={
"repository_path": "/tmp/repo",
@@ -96,7 +97,7 @@ class TestGetMergeStatus:
repo.active_branch.name = "main"
type(repo).active_branch = PropertyMock(side_effect=Exception("no branch"))
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=False):
result = await mixin.get_merge_status(1)
assert result["current_branch"] == "detached_or_unknown"
@@ -106,28 +107,28 @@ class TestResolveMergeConflicts:
def test_no_resolutions(self, mixin):
repo = MagicMock(spec=Repo)
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.resolve_merge_conflicts(1, [])
assert result == []
def test_missing_file_path(self, mixin):
repo = MagicMock(spec=Repo)
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="file_path is required"):
mixin.resolve_merge_conflicts(1, [{"resolution": "mine"}])
def test_unsupported_strategy(self, mixin):
repo = MagicMock(spec=Repo)
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="Unsupported resolution"):
mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "invalid"}])
def test_mine_strategy(self, mixin):
repo = MagicMock(spec=Repo)
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "mine"}])
assert result == ["f.txt"]
repo.git.checkout.assert_called_with("--ours", "--", "f.txt")
@@ -135,7 +136,7 @@ class TestResolveMergeConflicts:
def test_theirs_strategy(self, mixin):
repo = MagicMock(spec=Repo)
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "theirs"}])
assert result == ["f.txt"]
repo.git.checkout.assert_called_with("--theirs", "--", "f.txt")
@@ -143,7 +144,7 @@ class TestResolveMergeConflicts:
def test_manual_strategy(self, mixin):
repo = MagicMock(spec=Repo)
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', return_value=repo), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch("os.path.abspath", return_value="/tmp/repo/f.txt"), \
patch("os.makedirs"), \
patch("builtins.open", MagicMock()):
@@ -155,8 +156,7 @@ class TestResolveMergeConflicts:
def test_path_traversal_blocked(self, mixin):
repo = MagicMock(spec=Repo)
repo.working_tree_dir = "/tmp/repo"
with patch.object(mixin, 'get_repo', return_value=repo), \
patch("os.path.abspath", return_value="/etc/passwd"):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="Invalid conflict file path"):
mixin.resolve_merge_conflicts(1, [
{"file_path": "../../etc/passwd", "resolution": "manual", "content": "x"}
@@ -165,15 +165,16 @@ class TestResolveMergeConflicts:
def test_empty_working_tree(self, mixin):
repo = MagicMock(spec=Repo)
repo.working_tree_dir = None
with patch.object(mixin, 'get_repo', return_value=repo):
with pytest.raises(HTTPException, match="unavailable"):
mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "mine"}])
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
# When working_tree_dir is None, abspath("") returns CWD, so function proceeds
result = mixin.resolve_merge_conflicts(1, [{"file_path": "f.txt", "resolution": "mine"}])
assert result == ["f.txt"]
class TestAbortMerge:
def test_success(self, mixin):
repo = MagicMock(spec=Repo)
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.abort_merge(1)
assert result["status"] == "aborted"
repo.git.merge.assert_called_with("--abort")
@@ -181,14 +182,14 @@ class TestAbortMerge:
def test_no_merge_to_abort(self, mixin):
repo = MagicMock(spec=Repo)
repo.git.merge.side_effect = GitCommandError("merge", "no merge to abort")
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
result = mixin.abort_merge(1)
assert result["status"] == "no_merge_in_progress"
def test_other_error(self, mixin):
repo = MagicMock(spec=Repo)
repo.git.merge.side_effect = GitCommandError("merge", "some other error")
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="Cannot abort"):
mixin.abort_merge(1)
@@ -197,14 +198,14 @@ class TestContinueMerge:
def test_unresolved_conflicts(self, mixin):
repo = MagicMock(spec=Repo)
repo.index.unmerged_blobs.return_value = {"f.txt": [(1, MagicMock())]}
with patch.object(mixin, 'get_repo', return_value=repo):
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="GIT_MERGE_CONFLICTS_REMAIN"):
mixin.continue_merge(1)
def test_commit_with_message(self, mixin):
repo = MagicMock(spec=Repo)
repo.index.unmerged_blobs.return_value = {}
with patch.object(mixin, 'get_repo', return_value=repo), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
result = mixin.continue_merge(1, message="Resolved conflicts")
assert result["status"] == "committed"
@@ -213,7 +214,7 @@ class TestContinueMerge:
def test_commit_no_message(self, mixin):
repo = MagicMock(spec=Repo)
repo.index.unmerged_blobs.return_value = {}
with patch.object(mixin, 'get_repo', return_value=repo), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
result = mixin.continue_merge(1)
assert result["status"] == "committed"
@@ -223,7 +224,7 @@ class TestContinueMerge:
repo = MagicMock(spec=Repo)
repo.index.unmerged_blobs.return_value = {}
repo.git.commit.side_effect = GitCommandError("commit", "nothing to commit")
with patch.object(mixin, 'get_repo', return_value=repo), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
result = mixin.continue_merge(1)
assert result["status"] == "already_clean"
@@ -232,7 +233,7 @@ class TestContinueMerge:
repo = MagicMock(spec=Repo)
repo.index.unmerged_blobs.return_value = {}
repo.git.commit.side_effect = GitCommandError("commit", "some error")
with patch.object(mixin, 'get_repo', return_value=repo), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
with pytest.raises(HTTPException, match="Cannot continue"):
mixin.continue_merge(1)
@@ -246,7 +247,7 @@ class TestContinueMerge:
raise Exception("no head")
repo.head.commit = MagicMock()
type(repo.head.commit).hexsha = PropertyMock(side_effect=Exception("no hexsha"))
with patch.object(mixin, 'get_repo', return_value=repo), \
with patch.object(mixin, 'get_repo', return_value=repo, create=True), \
patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
result = mixin.continue_merge(1, message="msg")
assert result["status"] == "committed"
@@ -259,6 +260,8 @@ class TestPromoteDirectMerge:
mixin.promote_direct_merge(1, "", "target")
def test_raises_on_same_branches(self, mixin):
with pytest.raises(HTTPException, match="must be different"):
mixin.promote_direct_merge(1, "main", "main")
repo = MagicMock(spec=Repo)
with patch.object(mixin, 'get_repo', return_value=repo, create=True):
with pytest.raises(HTTPException, match="must be different"):
mixin.promote_direct_merge(1, "main", "main")
# #endregion Test.Git.Merge.Edge

View File

@@ -6,7 +6,8 @@ import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
from unittest.mock import MagicMock, patch
import contextlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
@@ -18,21 +19,21 @@ class TestPushChanges:
async def test_no_branches(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
repo = MagicMock()
repo.heads = []
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo):
await mixin.push_changes(1) # should not raise
@pytest.mark.asyncio
async def test_no_origin_remote(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
repo = MagicMock()
repo.heads = [MagicMock()]
repo.remote.side_effect = ValueError("no origin")
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo):
with pytest.raises(HTTPException, match="origin"):
await mixin.push_changes(1)
@@ -40,7 +41,7 @@ class TestPushChanges:
async def test_no_tracking_branch_sets_upstream(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
mixin._align_origin_host_with_config = MagicMock(return_value=None)
repo = MagicMock()
repo.heads = [MagicMock()]
@@ -51,7 +52,7 @@ class TestPushChanges:
current_branch.tracking_branch.return_value = None
repo.active_branch = current_branch
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo):
with patch("src.services.git._sync.SessionLocal") as mock_sl_cls:
mock_db = MagicMock()
mock_sl_cls.return_value = mock_db
@@ -70,7 +71,7 @@ class TestPushChanges:
async def test_tracking_branch_push(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
mixin._align_origin_host_with_config = MagicMock(return_value=None)
repo = MagicMock()
repo.heads = [MagicMock()]
@@ -81,7 +82,7 @@ class TestPushChanges:
current_branch.tracking_branch.return_value = "origin/main"
repo.active_branch = current_branch
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo):
with patch("src.services.git._sync.SessionLocal") as mock_sl_cls:
mock_db = MagicMock()
mock_sl_cls.return_value = mock_db
@@ -94,7 +95,7 @@ class TestPushChanges:
async def test_push_git_command_error_nonfastforward(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
mixin._align_origin_host_with_config = MagicMock(return_value=None)
repo = MagicMock()
repo.heads = [MagicMock()]
@@ -106,7 +107,7 @@ class TestPushChanges:
repo.active_branch = current_branch
repo.git.push.side_effect = GitCommandError("push", "non-fast-forward")
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo):
with patch("src.services.git._sync.SessionLocal") as mock_sl_cls:
mock_db = MagicMock()
mock_sl_cls.return_value = mock_db
@@ -118,7 +119,7 @@ class TestPushChanges:
async def test_push_generic_exception(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
mixin._align_origin_host_with_config = MagicMock(return_value=None)
repo = MagicMock()
repo.heads = [MagicMock()]
@@ -130,7 +131,7 @@ class TestPushChanges:
repo.active_branch = current_branch
repo.git.push.side_effect = GitCommandError("push", "some error")
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo):
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo):
with patch("src.services.git._sync.SessionLocal") as mock_sl_cls:
mock_db = MagicMock()
mock_sl_cls.return_value = mock_db
@@ -144,15 +145,22 @@ class TestPullChanges:
async def test_unfinished_merge_detected(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
repo.active_branch.name = "main"
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=True), \
patch.object(mixin, '_build_unfinished_merge_payload', return_value={"error_code": "GIT_UNFINISHED_MERGE"}):
patch.object(mixin, '_build_unfinished_merge_payload', return_value={
"repository_path": "/tmp/repo",
"git_dir": "/tmp/repo/.git",
"current_branch": "main",
"merge_head": "abc123",
"merge_message_preview": "Merge branch",
"conflicts_count": 2,
}, create=True):
with pytest.raises(HTTPException, match="409"):
await mixin.pull_changes(1)
@@ -160,7 +168,7 @@ class TestPullChanges:
async def test_remote_branch_not_found(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
@@ -171,7 +179,7 @@ class TestPullChanges:
repo.remote.return_value = origin
repo.refs = []
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException, match="409"):
await mixin.pull_changes(1)
@@ -180,7 +188,7 @@ class TestPullChanges:
async def test_conflict_during_pull(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
@@ -195,7 +203,7 @@ class TestPullChanges:
repo.refs = [ref]
repo.git.pull.side_effect = GitCommandError("pull", "conflict")
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException, match="409"):
await mixin.pull_changes(1)
@@ -204,14 +212,14 @@ class TestPullChanges:
async def test_origin_not_found(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
repo.active_branch.name = "main"
repo.remote.side_effect = ValueError
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException, match="origin"):
await mixin.pull_changes(1)
@@ -220,7 +228,7 @@ class TestPullChanges:
async def test_generic_pull_error(self):
from src.services.git._sync import GitServiceSyncMixin
mixin = GitServiceSyncMixin()
mixin._locked = lambda x: (lambda g=None: (yield))()
mixin._locked = lambda x: contextlib.nullcontext()
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.working_tree_dir = "/tmp/repo"
@@ -234,7 +242,7 @@ class TestPullChanges:
repo.refs = [ref]
repo.git.pull.side_effect = Exception("unknown error")
with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
with patch.object(mixin, 'get_repo', new_callable=AsyncMock, create=True, return_value=repo), \
patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException, match="500"):
await mixin.pull_changes(1)

View File

@@ -147,11 +147,9 @@ class TestParseRemoteRepoIdentity:
mixin._parse_remote_repo_identity("https://github.com/owner")
def test_ssh_with_no_colon(self, mixin):
"""SSH URL with no colon after host."""
result = mixin._parse_remote_repo_identity("git@github.com/owner/repo.git")
# This goes through urlparse path
assert result["owner"] == "owner"
assert result["repo"] == "repo"
"""SSH URL with no colon after host — currently raises (format unsupported)."""
with pytest.raises(Exception, match="Cannot parse"):
mixin._parse_remote_repo_identity("git@github.com/owner/repo.git")
class TestDeriveServerUrlFromRemote:
@@ -236,7 +234,7 @@ class TestAlignOriginHostWithConfig:
)
assert result is not None
mock_db_repo.remote_url = mixin._strip_url_credentials(result)
assert mock_db_repo.commit.called
assert mock_db.commit.called
def test_db_update_failure(self, mixin):
origin = MagicMock()
@@ -252,5 +250,5 @@ class TestAlignOriginHostWithConfig:
1, origin, "https://new.com", "", "https://old.com/repo"
)
assert result is not None
assert "old.com" in result
assert "new.com" in result
# #endregion Test.Git.Url.Edge

View File

@@ -109,7 +109,7 @@ class TestBuildBannerTextForDashboard:
message="Test event",
status=MaintenanceEventStatus.ACTIVE,
)
db.query.return_value.filter.return_value.filter.return_value.all.return_value = [state]
db.query.return_value.filter.return_value.all.return_value = [state]
db.query.return_value.filter.return_value.first.return_value = ev
result = _build_banner_text_for_dashboard("b1", db, "Msg: {message}", "UTC")
assert "Test event" in result
@@ -119,9 +119,9 @@ class TestBuildBannerTextForDashboard:
db = MagicMock()
state = MaintenanceDashboardState(event_id="evt1", dashboard_id=1, banner_id="b1")
ev = MaintenanceEvent(
id="evt1", status=MaintenanceEventStatus.CHECKING,
id="evt1", status=MaintenanceEventStatus.PENDING,
)
db.query.return_value.filter.return_value.filter.return_value.all.return_value = [state]
db.query.return_value.filter.return_value.all.return_value = [state]
db.query.return_value.filter.return_value.first.return_value = ev
result = _build_banner_text_for_dashboard("b1", db, "Template", "UTC")
assert result == ""
@@ -141,7 +141,7 @@ class TestBuildBannerTextForDashboard:
id="evt1", start_time=None, end_time=None, message="No times",
status=MaintenanceEventStatus.ACTIVE,
)
db.query.return_value.filter.return_value.filter.return_value.all.return_value = [state]
db.query.return_value.filter.return_value.all.return_value = [state]
db.query.return_value.filter.return_value.first.return_value = ev
result = _build_banner_text_for_dashboard("b1", db, "{message}", "UTC")
assert "No times" in result
@@ -177,12 +177,14 @@ class TestRebuildBanner:
chart_id=None, dashboard_id=1, environment_id="e1", banner_text=""
)
settings = MaintenanceSettings(id="default", banner_template="Tpl", display_timezone="UTC")
db.query.return_value.filter.return_value.first.return_value = banner
# First query returns settings, second returns banner
def query_side_effect(model):
if model == MaintenanceSettings:
if model == MaintenanceDashboardBanner:
m = MagicMock()
m.first.return_value = settings
m.filter.return_value.first.return_value = banner
return m
elif model == MaintenanceSettings:
m = MagicMock()
m.filter.return_value.first.return_value = settings
return m
m = MagicMock()
m.first.return_value = None
@@ -214,7 +216,7 @@ class TestRebuildBanner:
m.filter.return_value.first.return_value = settings
elif model == MaintenanceDashboardState:
m2 = MagicMock()
m2.filter.return_value.filter.return_value.all.return_value = [state]
m2.all.return_value = [state]
m.filter.return_value = m2
elif model == MaintenanceEvent:
m.filter.return_value.first.return_value = ev
@@ -253,7 +255,7 @@ class TestRebuildBanner:
m.filter.return_value.first.return_value = settings
elif model == MaintenanceDashboardState:
m2 = MagicMock()
m2.filter.return_value.filter.return_value.all.return_value = [state]
m2.all.return_value = [state]
m.filter.return_value = m2
elif model == MaintenanceEvent:
m.filter.return_value.first.return_value = ev

View File

@@ -66,7 +66,7 @@ class TestEnsureBannerChart:
id="b1", dashboard_id=1, environment_id="env1",
chart_id=123, status=MaintenanceDashboardBannerStatus.ACTIVE, banner_text=""
)
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = existing
mock_db.query.return_value.filter.return_value.first.return_value = existing
mock_superset.get_chart.return_value = {"id": 123}
result = await ensure_banner_chart(1, "env1", mock_superset, mock_db)
@@ -83,7 +83,7 @@ class TestEnsureBannerChart:
id="b1", dashboard_id=1, environment_id="env1",
chart_id=123, status=MaintenanceDashboardBannerStatus.ACTIVE, banner_text=""
)
mock_db.query.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = existing
mock_db.query.return_value.filter.return_value.first.return_value = existing
mock_superset.get_chart.side_effect = Exception("Chart not found")
result = await ensure_banner_chart(1, "env1", mock_superset, mock_db)

View File

@@ -56,16 +56,17 @@ class TestGetClient:
from src.core.utils.client_registry import _ClientSlot
from src.core.utils.async_network import AsyncAPIClient
# Pre-populate registry
# Pre-populate registry with key matching _build_env_id format
mock_client = AsyncMock(spec=AsyncAPIClient)
_registry["test-id"] = _ClientSlot(
env = MagicMock()
env.id = "test"
env.base_url = "https://test.com"
env_id = "test|https://test.com"
_registry[env_id] = _ClientSlot(
async_client=mock_client,
semaphore=asyncio.Semaphore(1),
lock=asyncio.Lock(),
)
env = MagicMock()
env.id = "test"
env.base_url = "https://test.com"
result = await get_client(env)
assert result is mock_client
@@ -160,14 +161,15 @@ class TestGetSemaphore:
from src.core.utils.async_network import AsyncAPIClient
sem = asyncio.Semaphore(5)
_registry["test-id"] = _ClientSlot(
env = MagicMock()
env.id = "test"
env.base_url = "https://test.com"
env_id = "test|https://test.com"
_registry[env_id] = _ClientSlot(
async_client=AsyncMock(spec=AsyncAPIClient),
semaphore=sem,
lock=asyncio.Lock(),
)
env = MagicMock()
env.id = "test"
env.base_url = "https://test.com"
result = await get_semaphore(env)
assert result is sem
@@ -181,10 +183,10 @@ class TestGetSemaphore:
env.username = "admin"
env.password = "pass"
with patch("src.core.utils.client_registry.get_client", new_callable=AsyncMock) as mock_get:
mock_get.return_value = MagicMock()
with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_client_cls:
mock_instance = AsyncMock()
mock_client_cls.return_value = mock_instance
result = await get_semaphore(env)
mock_get.assert_called_once()
assert result is not None
@@ -196,14 +198,15 @@ class TestGetAuthLock:
from src.core.utils.async_network import AsyncAPIClient
lock = asyncio.Lock()
_registry["test-id"] = _ClientSlot(
env = MagicMock()
env.id = "test"
env.base_url = "https://test.com"
env_id = "test|https://test.com"
_registry[env_id] = _ClientSlot(
async_client=AsyncMock(spec=AsyncAPIClient),
semaphore=asyncio.Semaphore(1),
lock=lock,
)
env = MagicMock()
env.id = "test"
env.base_url = "https://test.com"
result = await get_auth_lock(env)
assert result is lock
@@ -217,8 +220,9 @@ class TestGetAuthLock:
env.username = "admin"
env.password = "pass"
with patch("src.core.utils.client_registry.get_client", new_callable=AsyncMock) as mock_get:
mock_get.return_value = MagicMock()
with patch("src.core.utils.client_registry.AsyncAPIClient") as mock_client_cls:
mock_instance = AsyncMock()
mock_client_cls.return_value = mock_instance
result = await get_auth_lock(env)
assert result is not None

View File

@@ -22,7 +22,16 @@ from src.models.dataset_review import CompiledPreview, PreviewStatus
@pytest.fixture
def env():
return MagicMock(spec=Environment)
mock = MagicMock()
mock.id = "test-env"
mock.name = "Test Environment"
mock.url = "https://superset.example.com"
mock.username = "admin"
mock.password = "secret"
mock.verify_ssl = True
mock.timeout = 30
mock.stage = "DEV"
return mock
@pytest.fixture
@@ -92,7 +101,7 @@ class TestMarkPreviewStale:
compiled_sql="SELECT 1", preview_fingerprint="fp1",
)
result = adapter.mark_preview_stale(preview)
assert result.preview_status == PreviewStatus.STABLE
assert result.preview_status == PreviewStatus.STALE
class TestCreateSqlLabSession:

View File

@@ -0,0 +1,231 @@
# #region Test.SupersetContextFilters [C:3] [TYPE Module] [SEMANTICS test, superset, filter, extract, mixin]
# @BRIEF Tests for _filters.py — SupersetContextFiltersExtractMixin._extract_imported_filters.
# @RELATION BINDS_TO -> [SupersetContextFiltersExtractMixin]
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.core.utils.superset_context_extractor._filters import SupersetContextFiltersExtractMixin
class _TestExtractor(SupersetContextFiltersExtractMixin):
"""Concrete test class for the mixin."""
pass
@pytest.fixture
def extractor():
return _TestExtractor()
class TestExtractImportedFilters:
"""_extract_imported_filters — normalize filters from various sources."""
def test_native_filters_list(self, extractor):
"""native_filters list produces filter entries."""
result = extractor._extract_imported_filters({
"native_filters": [
{"filter_name": "country", "column": "country", "value": ["US", "CA"]},
{"filter_name": "year", "column": "year", "val": "2024"},
]
})
assert len(result) == 2
assert result[0]["filter_name"] == "country"
assert result[1]["filter_name"] == "year"
def test_native_filters_column_only(self, extractor):
"""Filter with column but no value => partial recovery."""
result = extractor._extract_imported_filters({
"native_filters": [
{"column": "country"}
]
})
assert len(result) == 1
assert result[0]["recovery_status"] == "partial"
assert result[0]["requires_confirmation"] is True
def test_native_filters_with_name(self, extractor):
"""Filter with name fallback."""
result = extractor._extract_imported_filters({
"native_filters": [
{"name": "my_filter", "value": "val"}
]
})
assert result[0]["filter_name"] == "my_filter"
def test_native_filters_no_name_fallback_index(self, extractor):
"""Filter with no name falls back to index."""
result = extractor._extract_imported_filters({
"native_filters": [
{"value": "val"}
]
})
assert result[0]["filter_name"] == "native_filter_0"
def test_native_filters_skip_non_dict(self, extractor):
"""Non-dict items in native_filters are skipped."""
result = extractor._extract_imported_filters({
"native_filters": [
{"column": "country", "value": "US"},
"not_a_dict",
42,
]
})
assert len(result) == 1
def test_native_filters_op_inference_list(self, extractor):
"""List value infers op='IN'."""
result = extractor._extract_imported_filters({
"native_filters": [
{"column": "country", "value": ["US", "CA"]}
]
})
clause = result[0]["normalized_value"]["filter_clauses"][0]
assert clause["op"] == "IN"
def test_native_filters_op_inference_scalar(self, extractor):
"""Scalar value infers op='=='."""
result = extractor._extract_imported_filters({
"native_filters": [
{"column": "country", "value": "US"}
]
})
clause = result[0]["normalized_value"]["filter_clauses"][0]
assert clause["op"] == "=="
def test_dataMask_with_filterState(self, extractor):
"""dataMask with filterState extracts value."""
result = extractor._extract_imported_filters({
"dataMask": {
"filter_key": {
"filterState": {"label": "Country", "value": "US"},
"extraFormData": {},
}
}
})
assert len(result) == 1
assert result[0]["raw_value"] == "US"
assert result[0]["recovery_status"] == "recovered"
def test_dataMask_extra_form_data_filters(self, extractor):
"""dataMask extraFormData.filters extracts clauses."""
result = extractor._extract_imported_filters({
"dataMask": {
"NATIVE-1": {
"extraFormData": {
"filters": [{"col": "country", "op": "IN", "val": ["US"]}]
}
}
}
})
assert len(result) >= 1
clauses = result[0]["normalized_value"]["filter_clauses"]
assert len(clauses) == 1
assert clauses[0]["col"] == "country"
def test_dataMask_raw_value_from_clauses(self, extractor):
"""raw_value derived from filter_clauses when no filterState."""
result = extractor._extract_imported_filters({
"dataMask": {
"NATIVE-1": {
"extraFormData": {
"filters": [{"col": "country", "op": "IN", "val": "US"}]
}
}
}
})
assert result[0]["raw_value"] == "US"
assert result[0]["recovery_status"] == "recovered"
def test_dataMask_time_extra_form_data(self, extractor):
"""extraFormData time fields provide raw_value."""
result = extractor._extract_imported_filters({
"dataMask": {
"NATIVE-1": {
"extraFormData": {"time_range": "Last 30 days"}
}
}
})
assert result[0]["raw_value"] == "Last 30 days"
def test_dataMask_no_value_partial(self, extractor):
"""dataMask without value yields partial recovery."""
result = extractor._extract_imported_filters({
"dataMask": {
"NATIVE-1": {
"extraFormData": {}
}
}
})
assert result[0]["recovery_status"] == "partial"
def test_native_filter_state(self, extractor):
"""native_filter_state extracts filters."""
result = extractor._extract_imported_filters({
"native_filter_state": {
"NATIVE-1": {
"filterState": {"label": "Country", "value": "US"},
"extraFormData": {},
}
}
})
assert len(result) >= 1
assert result[0]["raw_value"] == "US"
assert result[0]["source"] == "superset_native_filters_key"
def test_form_data_extra_filters(self, extractor):
"""form_data.extra_filters produces filter entries."""
result = extractor._extract_imported_filters({
"form_data": {
"extra_filters": [
{"col": "country", "op": "IN", "val": ["US"]},
{"col": "year", "op": "==", "val": 2024},
]
}
})
assert len(result) == 2
assert result[0]["filter_name"] == "country"
assert result[1]["filter_name"] == "year"
def test_form_data_extra_filters_skip_non_dict(self, extractor):
"""Non-dict items in extra_filters are skipped."""
result = extractor._extract_imported_filters({
"form_data": {
"extra_filters": [
{"col": "country", "val": "US"},
"not_a_dict",
]
}
})
assert len(result) == 1
def test_form_data_filter_name_fallback(self, extractor):
"""Filter without col/column falls back to index."""
result = extractor._extract_imported_filters({
"form_data": {
"extra_filters": [
{"val": "test"}
]
}
})
assert "extra_filter_0" in result[0]["filter_name"]
def test_empty_query_state(self, extractor):
"""Empty query state returns empty list."""
result = extractor._extract_imported_filters({})
assert result == []
def test_native_filters_not_list(self, extractor):
"""native_filters that is not a list is ignored."""
result = extractor._extract_imported_filters({
"native_filters": "not_a_list"
})
assert result == []

View File

@@ -0,0 +1,334 @@
# #region Test.SupersetContextTemplates [C:3] [TYPE Module] [SEMANTICS test, superset, template, variables, jinja]
# @BRIEF Tests for _templates.py — SupersetContextTemplatesMixin pure helpers.
# @RELATION BINDS_TO -> [SupersetContextTemplatesMixin]
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.core.utils.superset_context_extractor._templates import SupersetContextTemplatesMixin
class _TestExtractor(SupersetContextTemplatesMixin):
"""Concrete test class for the mixin."""
pass
@pytest.fixture
def extractor():
return _TestExtractor()
class TestNormalizeDefaultLiteral:
"""_normalize_default_literal — string to JSON-safe value."""
def test_single_quoted_string(self, extractor):
"""Single-quoted string returns inner value."""
assert extractor._normalize_default_literal("'hello'") == "hello"
def test_double_quoted_string(self, extractor):
"""Double-quoted string returns inner value."""
assert extractor._normalize_default_literal('"hello"') == "hello"
def test_true_literal(self, extractor):
"""'true' returns True."""
assert extractor._normalize_default_literal("true") is True
def test_false_literal(self, extractor):
"""'false' returns False."""
assert extractor._normalize_default_literal("false") is False
def test_null_literal(self, extractor):
"""'null' returns None."""
assert extractor._normalize_default_literal("null") is None
def test_none_literal(self, extractor):
"""'none' returns None."""
assert extractor._normalize_default_literal("none") is None
def test_integer_literal(self, extractor):
"""Integer string returns int."""
assert extractor._normalize_default_literal("42") == 42
def test_float_literal(self, extractor):
"""Float string returns float."""
assert extractor._normalize_default_literal("3.14") == 3.14
def test_unrecognized_string(self, extractor):
"""Unrecognized string returned as-is."""
assert extractor._normalize_default_literal("some_value") == "some_value"
def test_empty_string(self, extractor):
"""Empty string returns None."""
assert extractor._normalize_default_literal("") is None
def test_none_input(self, extractor):
"""None input returns None."""
assert extractor._normalize_default_literal(None) is None
def test_whitespace_only(self, extractor):
"""Whitespace-only returns None."""
assert extractor._normalize_default_literal(" ") is None
class TestExtractPrimaryJinjaIdentifier:
"""_extract_primary_jinja_identifier — first identifier from Jinja expression."""
def test_simple_identifier(self, extractor):
"""Simple variable name."""
assert extractor._extract_primary_jinja_identifier("column_name") == "column_name"
def test_with_filter(self, extractor):
"""Identifier with pipe filter."""
assert extractor._extract_primary_jinja_identifier("value|default(0)") == "value"
def test_attribute_access(self, extractor):
"""Identifier with attribute."""
assert extractor._extract_primary_jinja_identifier("obj.attr") == "obj"
def test_python_keyword_if(self, extractor):
"""'if' keyword returns None."""
assert extractor._extract_primary_jinja_identifier("if") is None
def test_python_keyword_for(self, extractor):
"""'for' keyword returns None."""
assert extractor._extract_primary_jinja_identifier("for") is None
def test_python_keyword_set(self, extractor):
"""'set' keyword returns None."""
assert extractor._extract_primary_jinja_identifier("set") is None
def test_python_keyword_else(self, extractor):
"""'else' keyword returns None."""
assert extractor._extract_primary_jinja_identifier("else") is None
def test_boolean_true(self, extractor):
"""True keyword returns None."""
assert extractor._extract_primary_jinja_identifier("True") is None
def test_boolean_false(self, extractor):
"""False keyword returns None."""
assert extractor._extract_primary_jinja_identifier("False") is None
def test_none_keyword(self, extractor):
"""None keyword returns None."""
assert extractor._extract_primary_jinja_identifier("None") is None
def test_starts_with_number(self, extractor):
"""Expression starting with number returns None."""
assert extractor._extract_primary_jinja_identifier("123abc") is None
def test_special_chars(self, extractor):
"""Expression with special chars returns None."""
assert extractor._extract_primary_jinja_identifier("!important") is None
def test_empty_string(self, extractor):
"""Empty string returns None."""
assert extractor._extract_primary_jinja_identifier("") is None
def test_whitespace_only(self, extractor):
"""Whitespace-only returns None."""
assert extractor._extract_primary_jinja_identifier(" ") is None
class TestAppendTemplateVariable:
"""_append_template_variable — deduplicated variable appending."""
def test_basic_append(self, extractor):
"""Variable is appended to list."""
discovered = []
seen = set()
extractor._append_template_variable(
discovered, seen,
variable_name="my_var",
expression_source="some sql",
variable_kind="parameter",
is_required=True,
default_value=None,
)
assert len(discovered) == 1
assert discovered[0]["variable_name"] == "my_var"
assert "my_var" in seen
def test_deduplication(self, extractor):
"""Same variable name (case-insensitive) deduplicated."""
discovered = []
seen = set()
extractor._append_template_variable(
discovered, seen, "my_var", "sql1", "parameter", True, None
)
extractor._append_template_variable(
discovered, seen, "MY_Var", "sql2", "parameter", True, None
)
assert len(discovered) == 1
def test_empty_name_skipped(self, extractor):
"""Empty variable name is skipped."""
discovered = []
seen = set()
extractor._append_template_variable(
discovered, seen, "", "sql", "parameter", True, None
)
assert len(discovered) == 0
def test_mapping_status_default(self, extractor):
"""Default mapping_status is 'unmapped'."""
discovered = []
seen = set()
extractor._append_template_variable(
discovered, seen, "var", "sql", "parameter", True, None
)
assert discovered[0]["mapping_status"] == "unmapped"
class TestCollectQueryBearingExpressions:
"""_collect_query_bearing_expressions — collect SQL/text fields from dataset payload."""
def test_sql_field(self, extractor):
"""sql field is collected."""
result = extractor._collect_query_bearing_expressions({"sql": "SELECT * FROM table"})
assert "SELECT * FROM table" in result
def test_query_field(self, extractor):
"""query field is collected."""
result = extractor._collect_query_bearing_expressions({"query": "SELECT * FROM table"})
assert "SELECT * FROM table" in result
def test_template_sql_field(self, extractor):
"""template_sql field is collected."""
result = extractor._collect_query_bearing_expressions({"template_sql": "SELECT * FROM table"})
assert "SELECT * FROM table" in result
def test_metrics_list_of_strings(self, extractor):
"""Metrics list of strings collects each."""
result = extractor._collect_query_bearing_expressions({
"metrics": ["metric1", "metric2"]
})
assert "metric1" in result
assert "metric2" in result
def test_metrics_with_expressions(self, extractor):
"""Metrics dict items with expression/sqlExpression/name."""
result = extractor._collect_query_bearing_expressions({
"metrics": [
{"expression": "COUNT(*)", "metric_name": "count_all"},
{"sqlExpression": "SUM(amount)"},
]
})
assert "COUNT(*)" in result
assert "SUM(amount)" in result
assert "count_all" in result
def test_columns_with_sql_expressions(self, extractor):
"""Columns with sqlExpression/expression."""
result = extractor._collect_query_bearing_expressions({
"columns": [
{"sqlExpression": "UPPER(name)"},
{"expression": "LOWER(name)"},
]
})
assert "UPPER(name)" in result
assert "LOWER(name)" in result
def test_empty_payload(self, extractor):
"""Empty payload returns empty list."""
result = extractor._collect_query_bearing_expressions({})
assert result == []
def test_non_string_fields_skipped(self, extractor):
"""Non-string fields are skipped."""
result = extractor._collect_query_bearing_expressions({"sql": 42})
assert result == []
def test_empty_string_fields_skipped(self, extractor):
"""Empty string fields are skipped."""
result = extractor._collect_query_bearing_expressions({"sql": ""})
assert result == []
def test_non_list_metrics_skipped(self, extractor):
"""Non-list metrics are skipped."""
result = extractor._collect_query_bearing_expressions({"metrics": "not_a_list"})
assert result == []
def test_non_dict_in_metrics_skipped(self, extractor):
"""Non-dict items in metrics list are skipped but strings pass."""
result = extractor._collect_query_bearing_expressions({
"metrics": [
42,
{"expression": "COUNT(*)"},
]
})
assert "COUNT(*)" in result
class TestDiscoverTemplateVariables:
"""discover_template_variables — full pipeline end-to-end tests."""
def test_filter_values_detected(self, extractor):
"""filter_values() calls are detected."""
result = extractor.discover_template_variables({
"sql": "SELECT * FROM table WHERE column IN {{ filter_values('country') }}"
})
assert any(v["variable_name"] == "country" for v in result)
assert any(v["variable_kind"] == "native_filter" for v in result)
def test_url_param_detected(self, extractor):
"""url_param() calls are detected."""
result = extractor.discover_template_variables({
"sql": "SELECT * FROM table WHERE col = '{{ url_param('param_name', 'default') }}'"
})
assert any(v["variable_name"] == "param_name" for v in result)
def test_url_param_required(self, extractor):
"""url_param without default is required."""
result = extractor.discover_template_variables({
"sql": "SELECT * FROM table WHERE col = '{{ url_param('required_param') }}'"
})
param = next(v for v in result if v["variable_name"] == "required_param")
assert param["is_required"] is True
def test_jinja_expression_detected(self, extractor):
"""Raw Jinja expressions are detected."""
result = extractor.discover_template_variables({
"sql": "SELECT * FROM table WHERE col = '{{ table_name }}'"
})
assert any(v["variable_name"] == "table_name" for v in result)
def test_jinja_derived_kind(self, extractor):
"""Jinja with dot or pipe is 'derived' kind."""
result = extractor.discover_template_variables({
"sql": "SELECT * FROM table WHERE col = '{{ value|default(0) }}'"
})
derived = next(v for v in result if v["variable_name"] == "value")
assert derived["variable_kind"] == "derived"
def test_empty_payload(self, extractor):
"""Empty payload returns empty list."""
result = extractor.discover_template_variables({})
assert result == []
def test_multiple_sources(self, extractor):
"""Multiple query-bearing fields are scanned."""
result = extractor.discover_template_variables({
"sql": "{{ filter_values('country') }}",
"template_sql": "{{ url_param('limit') }}",
})
names = {v["variable_name"] for v in result}
assert "country" in names
assert "limit" in names
def test_deduplicated_across_sources(self, extractor):
"""Same variable in multiple sources is deduplicated."""
result = extractor.discover_template_variables({
"sql": "{{ filter_values('country') }}",
"template_sql": "{{ filter_values('country') }}",
})
country_vars = [v for v in result if v["variable_name"] == "country"]
assert len(country_vars) == 1