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:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
207
backend/tests/plugins/translate/test_llm_parse.py
Normal file
207
backend/tests/plugins/translate/test_llm_parse.py
Normal 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 == {}
|
||||
@@ -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 == []
|
||||
290
backend/tests/plugins/translate/test_token_budget.py
Normal file
290
backend/tests/plugins/translate/test_token_budget.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user