diff --git a/backend/:memory:test_auth b/backend/:memory:test_auth
new file mode 100644
index 00000000..974eb816
Binary files /dev/null and b/backend/:memory:test_auth differ
diff --git a/backend/tests/api/test_agent_conversations.py b/backend/tests/api/test_agent_conversations.py
index 7bccb304..0780374a 100644
--- a/backend/tests/api/test_agent_conversations.py
+++ b/backend/tests/api/test_agent_conversations.py
@@ -32,10 +32,11 @@ def _make_client(overrides: dict | None = None) -> TestClient:
app.include_router(router)
app.include_router(agent_router)
+ _NOW = __import__("datetime").datetime.now()
mock_user = User(
id="user-1", username="testuser", email="test@x.com",
auth_source="LOCAL",
- created_at=__import__("datetime").datetime.now(),
+ created_at=_NOW,
roles=[RoleSchema(id="r1", name="User", description="", permissions=[])],
)
@@ -59,10 +60,11 @@ class TestListConversations:
mock_db.query.return_value = mock_query
mock_query.filter.return_value = mock_query
mock_query.count.return_value = 2
+ _NOW = __import__("datetime").datetime.now()
mock_conv = MagicMock()
mock_conv.id = "conv-1"
mock_conv.title = "Test Conversation"
- mock_conv.updated_at = None
+ mock_conv.updated_at = _NOW
mock_conv.messages = [MagicMock()]
mock_query.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [mock_conv, mock_conv]
@@ -130,8 +132,9 @@ class TestSaveConversation:
def test_update_existing(self):
existing_conv = MagicMock()
+ existing_conv.updated_at = __import__("datetime").datetime.now()
mock_db = MagicMock()
- mock_db.query.return_value.filter.return_value.first.side_effect = [existing_conv, None]
+ mock_db.query.return_value.filter.return_value.first.side_effect = [existing_conv, None, None]
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
@@ -152,8 +155,10 @@ class TestSaveConversation:
def test_skip_duplicate_message(self):
existing_msg = MagicMock()
+ existing_msg.id = "msg-2"
mock_db = MagicMock()
- mock_db.query.return_value.filter.return_value.first.side_effect = [None, existing_msg]
+ # 3 .first() calls: 1 for conv lookup → None, msg-1 lookup → None, msg-2 lookup → existing_msg
+ mock_db.query.return_value.filter.return_value.first.side_effect = [None, None, existing_msg]
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
@@ -168,6 +173,7 @@ class TestGetHistory:
def test_success(self):
mock_db = MagicMock()
+ _NOW = __import__("datetime").datetime.now()
mock_conv = MagicMock()
mock_conv.id = "conv-1"
mock_msg = MagicMock()
@@ -177,7 +183,7 @@ class TestGetHistory:
mock_msg.text = "Hello"
mock_msg.tool_calls = None
mock_msg.attachments = None
- mock_msg.created_at = None
+ mock_msg.created_at = _NOW
mock_conv.messages = [mock_msg]
mock_db.query.return_value.filter.return_value.first.return_value = mock_conv
diff --git a/backend/tests/api/test_dashboard_listing_routes.py b/backend/tests/api/test_dashboard_listing_routes.py
index 005be8b4..6f7adca5 100644
--- a/backend/tests/api/test_dashboard_listing_routes.py
+++ b/backend/tests/api/test_dashboard_listing_routes.py
@@ -305,4 +305,163 @@ class TestGetDashboards:
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&override_show_all=true")
assert resp.status_code == 200
+
+ def test_get_dashboards_git_filter_no_match(self, base_mocks):
+ """Git filter excludes non-matching dashboards."""
+ mock_rs = base_mocks["resource_service"]
+ mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
+ mock_rs.get_dashboards_with_status.return_value = [
+ {"id": 1, "title": "Main", "slug": "main", "owners": [],
+ "git_status": {"sync_status": "DIFF", "has_repo": True}},
+ {"id": 2, "title": "Other", "slug": "other", "owners": [],
+ "git_status": {"sync_status": "OK", "has_repo": True}},
+ ]
+
+ client = _make_client(base_mocks)
+ resp = client.get("/api/dashboards?env_id=env-1&filter_git_status=ok")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 1
+ assert data["dashboards"][0]["id"] == 2
+
+ def test_get_dashboards_actor_filter_non_list_owner(self, base_mocks):
+ """Actor filter with non-list owner value (string) doesn't crash."""
+ mock_rs = base_mocks["resource_service"]
+ mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
+ mock_rs.get_dashboards_with_status.return_value = [
+ {"id": 1, "title": "Main", "slug": "main", "owners": "admin"},
+ ]
+
+ client = _make_client(base_mocks)
+ resp = client.get("/api/dashboards?env_id=env-1&filter_actor=admin")
+ assert resp.status_code == 200
+
+ def test_get_dashboards_full_scan_with_search(self, base_mocks):
+ """Search in full scan path (triggered by column filter)."""
+ mock_rs = base_mocks["resource_service"]
+ mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
+ mock_rs.get_dashboards_with_status.return_value = [
+ {"id": 1, "title": "Revenue Dashboard", "slug": "revenue", "owners": [], "git_status": {"sync_status": "OK", "has_repo": True}},
+ {"id": 2, "title": "Sales Report", "slug": "sales", "owners": [], "git_status": {"sync_status": "DIFF", "has_repo": True}},
+ ]
+
+ # Use filter_git_status to force full_scan path + search narrows results
+ client = _make_client(base_mocks)
+ resp = client.get("/api/dashboards?env_id=env-1&search=revenue&filter_git_status=ok")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 1
+
+ def test_get_dashboards_slug_filter_full_scan(self, base_mocks):
+ """Slug-only filter applied in full scan path."""
+ mock_rs = base_mocks["resource_service"]
+ mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
+ mock_rs.get_dashboards_with_status.return_value = [
+ {"id": 1, "title": "No Slug", "slug": "", "owners": []},
+ {"id": 2, "title": "Has Slug", "slug": "has-slug", "owners": []},
+ ]
+
+ mocks = dict(base_mocks)
+ profile_svc = MagicMock()
+ profile_svc.get_dashboard_filter_binding.return_value = {
+ "superset_username": "",
+ "superset_username_normalized": "",
+ "show_only_my_dashboards": False,
+ "show_only_slug_dashboards": True,
+ }
+ profile_svc.matches_dashboard_actor.return_value = False
+ with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
+ MockPS.__module__ = "unittest.mock"
+ client = _make_client(mocks)
+ resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=true")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 1
+ assert data["dashboards"][0]["slug"] == "has-slug"
+
+ def test_get_dashboards_profile_filter_with_actor(self, base_mocks):
+ """Profile filter with actor alias matching."""
+ mock_rs = base_mocks["resource_service"]
+ mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
+ mock_rs.get_dashboards_with_status.return_value = [
+ {"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "testuser"}], "modified_by": None},
+ {"id": 2, "title": "Other Dash", "slug": "other", "owners": [{"username": "other"}], "modified_by": None},
+ ]
+
+ mocks = dict(base_mocks)
+ profile_svc = MagicMock()
+ profile_svc.get_dashboard_filter_binding.return_value = {
+ "superset_username": "testuser",
+ "superset_username_normalized": "testuser",
+ "show_only_my_dashboards": True,
+ "show_only_slug_dashboards": False,
+ }
+ profile_svc.matches_dashboard_actor.side_effect = lambda bound_username, owners, modified_by: (
+ any("testuser" in str(o) for o in (owners or []))
+ )
+ with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
+ MockPS.__module__ = "unittest.mock"
+ client = _make_client(mocks)
+ resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=true")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 1
+
+ def test_get_dashboards_profile_error_fallback(self, base_mocks):
+ """Profile service error falls back gracefully."""
+ mock_rs = base_mocks["resource_service"]
+ mock_rs.get_dashboards_page_with_status.return_value = {
+ "dashboards": [{"id": 1, "title": "Main", "slug": "main", "owners": []}],
+ "total": 1,
+ "total_pages": 1,
+ }
+ mocks = dict(base_mocks)
+ with patch("src.api.routes.dashboards._listing_routes.ProfileService") as MockPS:
+ MockPS.side_effect = Exception("Profile error")
+ client = _make_client(mocks)
+ resp = client.get("/api/dashboards?env_id=env-1")
+ assert resp.status_code == 200
+ assert resp.json()["total"] == 1
+
+ def test_get_dashboards_actor_filter_no_match(self, base_mocks):
+ """Actor filter with no matching owners."""
+ mock_rs = base_mocks["resource_service"]
+ mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
+ mock_rs.get_dashboards_with_status.return_value = [
+ {"id": 1, "title": "Main", "slug": "main", "owners": ["alice", "bob"]},
+ ]
+
+ client = _make_client(base_mocks)
+ resp = client.get("/api/dashboards?env_id=env-1&filter_actor=nonexistent")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["total"] == 0
+
+ def test_get_dashboards_full_scan_profile_and_slug(self, base_mocks):
+ """Both profile filter and slug filter active."""
+ mock_rs = base_mocks["resource_service"]
+ mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
+ mock_rs.get_dashboards_with_status.return_value = [
+ {"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "testuser"}], "modified_by": None},
+ {"id": 2, "title": "No Slug", "slug": "", "owners": [{"username": "testuser"}], "modified_by": None},
+ ]
+
+ mocks = dict(base_mocks)
+ profile_svc = MagicMock()
+ profile_svc.get_dashboard_filter_binding.return_value = {
+ "superset_username": "testuser",
+ "superset_username_normalized": "testuser",
+ "show_only_my_dashboards": True,
+ "show_only_slug_dashboards": True,
+ }
+ profile_svc.matches_dashboard_actor.return_value = True
+ with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
+ MockPS.__module__ = "unittest.mock"
+ client = _make_client(mocks)
+ resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=true")
+ assert resp.status_code == 200
+ data = resp.json()
+ # Only dash-1 has a slug
+ assert data["total"] == 1
+ assert data["dashboards"][0]["slug"] == "my-dash"
# #endregion Test.Api.DashboardListingRoutes
diff --git a/backend/tests/api/test_dataset_review_deps_unit.py b/backend/tests/api/test_dataset_review_deps_unit.py
index 965c87ba..fff5914b 100644
--- a/backend/tests/api/test_dataset_review_deps_unit.py
+++ b/backend/tests/api/test_dataset_review_deps_unit.py
@@ -51,6 +51,9 @@ from src.api.routes.dataset_review_pkg._dependencies import (
_commit_owned_session_mutation,
_enforce_session_version,
_get_latest_clarification_session_or_404,
+ _get_clarification_engine,
+ _get_orchestrator,
+ _get_repository,
_get_owned_field_or_404,
_get_owned_mapping_or_404,
_get_owned_session_or_404,
@@ -1232,4 +1235,44 @@ def test_serialize_empty_clarification_state():
#endregion
+# ── Dependency injection functions ──────────────────────────────────────
+
+
+#region test_get_repository [C:2]
+# @BRIEF _get_repository wraps a Session into DatasetReviewSessionRepository.
+def test_get_repository():
+ mock_db = MagicMock()
+ repo = _get_repository(db=mock_db)
+ from src.services.dataset_review.repositories.session_repository import (
+ DatasetReviewSessionRepository,
+ )
+ assert isinstance(repo, DatasetReviewSessionRepository)
+ assert repo.db is mock_db
+#endregion
+
+
+#region test_get_orchestrator [C:2]
+# @BRIEF _get_orchestrator builds DatasetReviewOrchestrator with deps.
+def test_get_orchestrator():
+ mock_repo = MagicMock()
+ mock_cm = MagicMock()
+ mock_tm = MagicMock()
+ orch = _get_orchestrator(repository=mock_repo, config_manager=mock_cm, task_manager=mock_tm)
+ from src.services.dataset_review.orchestrator import DatasetReviewOrchestrator
+ assert isinstance(orch, DatasetReviewOrchestrator)
+ assert orch.repository is mock_repo
+#endregion
+
+
+#region test_get_clarification_engine [C:2]
+# @BRIEF _get_clarification_engine builds ClarificationEngine with repo.
+def test_get_clarification_engine():
+ mock_repo = MagicMock()
+ engine = _get_clarification_engine(repository=mock_repo)
+ from src.services.dataset_review.clarification_engine import ClarificationEngine
+ assert isinstance(engine, ClarificationEngine)
+ assert engine.repository is mock_repo
+#endregion
+
+
#endregion Test.DatasetReview.DepsUnit
diff --git a/backend/tests/api/test_llm.py b/backend/tests/api/test_llm.py
index 1cdf178a..c52cb50a 100644
--- a/backend/tests/api/test_llm.py
+++ b/backend/tests/api/test_llm.py
@@ -301,7 +301,10 @@ class TestDeleteProvider:
def test_blocked_by_active_tasks(self):
mock_db = MagicMock()
- mock_db.query.return_value.filter.return_value.all.return_value = [MagicMock(id="t-1", name="Task")]
+ mock_task = MagicMock()
+ mock_task.id = "t-1"
+ mock_task.name = "Task"
+ mock_db.query.return_value.filter.return_value.all.return_value = [mock_task]
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
@@ -412,7 +415,7 @@ class TestProbeMaxImages:
from src.core.database import get_db
with (
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
- patch("src.api.routes.llm.AsyncOpenAI", return_value=mock_openai),
+ patch("openai.AsyncOpenAI", return_value=mock_openai),
):
client = _make_client({get_db: lambda: mock_db})
resp = client.post(f"{P}/providers/prov-1/probe-max-images")
@@ -466,7 +469,7 @@ class TestProbeMaxImages:
from src.core.database import get_db
with (
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
- patch("src.api.routes.llm.AsyncOpenAI", return_value=mock_openai),
+ patch("openai.AsyncOpenAI", return_value=mock_openai),
):
client = _make_client({get_db: lambda: mock_db})
resp = client.post(f"{P}/providers/prov-1/probe-max-images")
@@ -486,7 +489,7 @@ class TestProbeMaxImages:
from src.core.database import get_db
with (
patch("src.api.routes.llm.LLMProviderService", return_value=mock_svc),
- patch("src.api.routes.llm.AsyncOpenAI", return_value=mock_openai),
+ patch("openai.AsyncOpenAI", return_value=mock_openai),
):
client = _make_client({get_db: lambda: mock_db})
resp = client.post(f"{P}/providers/prov-1/probe-max-images")
diff --git a/backend/tests/api/test_mappings.py b/backend/tests/api/test_mappings.py
index 55478fac..76b0f310 100644
--- a/backend/tests/api/test_mappings.py
+++ b/backend/tests/api/test_mappings.py
@@ -42,7 +42,7 @@ def _make_client(overrides: dict | None = None) -> TestClient:
from src.schemas.auth import User, RoleSchema
app = FastAPI()
- app.include_router(router)
+ app.include_router(router, prefix="/api/mappings")
mock_user = User(
id="admin-1", username="admin", email="admin@x.com",
@@ -74,7 +74,7 @@ class TestGetMappings:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.get("/mappings")
+ resp = client.get("/api/mappings")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
@@ -89,7 +89,7 @@ class TestGetMappings:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.get("/mappings?source_env_id=env-1")
+ resp = client.get("/api/mappings?source_env_id=env-1")
assert resp.status_code == 200
def test_filter_by_target(self):
@@ -101,7 +101,7 @@ class TestGetMappings:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.get("/mappings?target_env_id=env-2")
+ resp = client.get("/api/mappings?target_env_id=env-2")
assert resp.status_code == 200
def test_empty(self):
@@ -110,7 +110,7 @@ class TestGetMappings:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.get("/mappings")
+ resp = client.get("/api/mappings")
assert resp.status_code == 200
assert resp.json() == []
@@ -136,7 +136,7 @@ class TestCreateMapping:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.post("/mappings", json=self.CREATE_PAYLOAD)
+ resp = client.post("/api/mappings", json=self.CREATE_PAYLOAD)
assert resp.status_code == 200
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
@@ -148,7 +148,7 @@ class TestCreateMapping:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.post("/mappings", json=self.CREATE_PAYLOAD)
+ resp = client.post("/api/mappings", json=self.CREATE_PAYLOAD)
assert resp.status_code == 200
mock_db.add.assert_not_called()
mock_db.commit.assert_called_once()
@@ -168,10 +168,10 @@ class TestSuggestMappingsApi:
from src.dependencies import get_config_manager
with patch("src.api.routes.mappings.MappingService") as MockMappingSvc:
instance = MagicMock()
- instance.get_suggestions = MagicMock(return_value=mock_suggestions)
+ instance.get_suggestions = AsyncMock(return_value=mock_suggestions)
MockMappingSvc.return_value = instance
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/mappings/suggest", json={
+ resp = client.post("/api/mappings/suggest", json={
"source_env_id": "env-1", "target_env_id": "env-2",
})
assert resp.status_code == 200
@@ -182,10 +182,10 @@ class TestSuggestMappingsApi:
from src.dependencies import get_config_manager
with patch("src.api.routes.mappings.MappingService") as MockMappingSvc:
instance = MagicMock()
- instance.get_suggestions = MagicMock(side_effect=RuntimeError("suggestion failed"))
+ instance.get_suggestions = AsyncMock(side_effect=RuntimeError("suggestion failed"))
MockMappingSvc.return_value = instance
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/mappings/suggest", json={
+ resp = client.post("/api/mappings/suggest", json={
"source_env_id": "env-1", "target_env_id": "env-2",
})
assert resp.status_code == 500
diff --git a/backend/tests/api/test_migration.py b/backend/tests/api/test_migration.py
index fe835501..c7948bc3 100644
--- a/backend/tests/api/test_migration.py
+++ b/backend/tests/api/test_migration.py
@@ -59,7 +59,7 @@ def _make_client(overrides: dict | None = None) -> TestClient:
# ── get_dashboards ──
class TestGetDashboards:
- """GET /environments/{env_id}/dashboards"""
+ """GET /api/environments/{env_id}/dashboards"""
def test_success(self):
mock_config = MagicMock()
@@ -72,7 +72,7 @@ class TestGetDashboards:
from src.dependencies import get_config_manager
with patch("src.api.routes.migration.AsyncSupersetClient", return_value=mock_client):
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.get("/environments/env-1/dashboards")
+ resp = client.get("/api/environments/env-1/dashboards")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
@@ -84,7 +84,7 @@ class TestGetDashboards:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.get("/environments/bad-env/dashboards")
+ resp = client.get("/api/environments/bad-env/dashboards")
assert resp.status_code == 404
@@ -115,7 +115,7 @@ class TestExecuteMigration:
get_config_manager: lambda: mock_config,
get_task_manager: lambda: mock_tm,
})
- resp = client.post("/migration/execute", json=self.SELECTION_PAYLOAD)
+ resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD)
assert resp.status_code == 200
data = resp.json()
assert data["task_id"] == "task-123"
@@ -126,7 +126,7 @@ class TestExecuteMigration:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/migration/execute", json=self.SELECTION_PAYLOAD)
+ resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD)
assert resp.status_code == 400
def test_invalid_target_env(self):
@@ -135,7 +135,16 @@ class TestExecuteMigration:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/migration/execute", json=self.SELECTION_PAYLOAD)
+ resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD)
+ assert resp.status_code == 400
+
+ def test_invalid_target_env(self):
+ mock_config = MagicMock()
+ mock_config.get_environments.return_value = [_make_env("env-2")]
+
+ from src.dependencies import get_config_manager
+ client = _make_client({get_config_manager: lambda: mock_config})
+ resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD)
assert resp.status_code == 400
def test_task_creation_fails(self):
@@ -152,7 +161,7 @@ class TestExecuteMigration:
get_config_manager: lambda: mock_config,
get_task_manager: lambda: mock_tm,
})
- resp = client.post("/migration/execute", json=self.SELECTION_PAYLOAD)
+ resp = client.post("/api/migration/execute", json=self.SELECTION_PAYLOAD)
assert resp.status_code == 500
@@ -181,7 +190,7 @@ class TestDryRunMigration:
from src.dependencies import get_config_manager
with patch("src.api.routes.migration.MigrationDryRunService", return_value=mock_dry_run):
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/migration/dry-run", json=self.SELECTION_PAYLOAD)
+ resp = client.post("/api/migration/dry-run", json=self.SELECTION_PAYLOAD)
assert resp.status_code == 200
assert resp.json()["summary"] == "ok"
@@ -192,7 +201,7 @@ class TestDryRunMigration:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/migration/dry-run", json={
+ resp = client.post("/api/migration/dry-run", json={
"source_env_id": "env-1", "target_env_id": "env-1", "selected_ids": [1],
})
assert resp.status_code == 400
@@ -203,7 +212,7 @@ class TestDryRunMigration:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/migration/dry-run", json=self.SELECTION_PAYLOAD)
+ resp = client.post("/api/migration/dry-run", json=self.SELECTION_PAYLOAD)
assert resp.status_code == 400
def test_no_dashboards_selected(self):
@@ -214,7 +223,7 @@ class TestDryRunMigration:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/migration/dry-run", json={
+ resp = client.post("/api/migration/dry-run", json={
"source_env_id": "env-1", "target_env_id": "env-2", "selected_ids": [],
})
assert resp.status_code == 400
@@ -231,7 +240,7 @@ class TestDryRunMigration:
from src.dependencies import get_config_manager
with patch("src.api.routes.migration.MigrationDryRunService", return_value=mock_dry_run):
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.post("/migration/dry-run", json=self.SELECTION_PAYLOAD)
+ resp = client.post("/api/migration/dry-run", json=self.SELECTION_PAYLOAD)
assert resp.status_code == 500
@@ -248,7 +257,7 @@ class TestGetMigrationSettings:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.get("/migration/settings")
+ resp = client.get("/api/migration/settings")
assert resp.status_code == 200
assert resp.json()["cron"] == "0 2 * * *"
@@ -265,7 +274,7 @@ class TestUpdateMigrationSettings:
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.put("/migration/settings", json={"cron": "0 3 * * *"})
+ resp = client.put("/api/migration/settings", json={"cron": "0 3 * * *"})
assert resp.status_code == 200
data = resp.json()
assert data["cron"] == "0 3 * * *"
@@ -276,7 +285,7 @@ class TestUpdateMigrationSettings:
mock_config = MagicMock()
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
- resp = client.put("/migration/settings", json={})
+ resp = client.put("/api/migration/settings", json={})
assert resp.status_code == 400
@@ -300,7 +309,7 @@ class TestGetResourceMappings:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.get("/migration/mappings-data")
+ resp = client.get("/api/migration/mappings-data")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
@@ -316,7 +325,7 @@ class TestGetResourceMappings:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.get("/migration/mappings-data?env_id=env-1&resource_type=database&search=test&skip=0&limit=10")
+ resp = client.get("/api/migration/mappings-data?env_id=env-1&resource_type=database&search=test&skip=0&limit=10")
assert resp.status_code == 200
@@ -345,7 +354,7 @@ class TestTriggerSyncNow:
get_config_manager: lambda: mock_config,
get_db: lambda: mock_db,
})
- resp = client.post("/migration/sync-now")
+ resp = client.post("/api/migration/sync-now")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "completed"
@@ -383,7 +392,7 @@ class TestTriggerSyncNow:
get_config_manager: lambda: mock_config,
get_db: lambda: mock_db,
})
- resp = client.post("/migration/sync-now")
+ resp = client.post("/api/migration/sync-now")
assert resp.status_code == 200
data = resp.json()
assert data["synced_count"] == 1
diff --git a/backend/tests/api/test_settings.py b/backend/tests/api/test_settings.py
index 64c1d363..21ff2215 100644
--- a/backend/tests/api/test_settings.py
+++ b/backend/tests/api/test_settings.py
@@ -58,16 +58,43 @@ def _make_client(overrides: dict | None = None) -> TestClient:
# ── get_settings ──
+def _make_real_config(environments=None):
+ """Build a real AppConfig with default settings for mock testing."""
+ from src.core.config_models import (
+ AppConfig, GlobalSettings, StorageConfig, LoggingConfig, CleanReleaseConfig,
+ )
+ gs = GlobalSettings(
+ storage=StorageConfig(
+ root_path="/tmp/storage",
+ backup_path="/tmp/backup",
+ repo_path="/tmp/repo",
+ backup_structure_pattern="{env_id}/{backup_id}",
+ repo_structure_pattern="{env_id}/{repo_id}",
+ filename_pattern="{name}_{timestamp}",
+ ),
+ logging=LoggingConfig(
+ level="INFO",
+ task_log_level="DEBUG",
+ file_path="/tmp/log.log",
+ enable_belief_state=True,
+ ),
+ clean_release=CleanReleaseConfig(
+ active_policy_id="",
+ active_registry_id="",
+ ),
+ default_environment_id="",
+ migration_sync_cron="0 0 * * *",
+ app_timezone="UTC",
+ )
+ return AppConfig(settings=gs, environments=environments or [])
+
+
class TestGetSettings:
"""GET /settings"""
def test_success(self):
mock_config = MagicMock()
- mock_cfg = MagicMock()
- mock_cfg.settings.llm = {}
- mock_cfg.settings.features = MagicMock()
- mock_cfg.environments = []
- mock_config.get_config.return_value = mock_cfg
+ mock_config.get_config.return_value = _make_real_config()
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
@@ -75,19 +102,23 @@ class TestGetSettings:
assert resp.status_code == 200
def test_password_masked(self):
+ 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",
+ )
+ real_config = _make_real_config(environments=[env])
+
mock_config = MagicMock()
- mock_cfg = MagicMock()
- mock_cfg.settings.llm = {}
- mock_cfg.settings.features = MagicMock()
- env = _make_env()
- mock_cfg.environments = [env]
- mock_config.get_config.return_value = mock_cfg
+ mock_config.get_config.return_value = real_config
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/settings")
assert resp.status_code == 200
- assert mock_cfg.environments[0].password == "********"
+ data = resp.json()
+ masked_env = data["environments"][0]
+ assert masked_env["password"] == "********"
class TestGetFeatures:
@@ -144,9 +175,17 @@ class TestGetStorageSettings:
"""GET /settings/storage"""
def test_success(self):
+ from src.core.config_models import StorageConfig
mock_config = MagicMock()
mock_cfg = MagicMock()
- mock_cfg.settings.storage = MagicMock()
+ mock_cfg.settings.storage = StorageConfig(
+ root_path="/tmp/storage",
+ backup_path="/tmp/backup",
+ repo_path="/tmp/repo",
+ backup_structure_pattern="{env_id}/{backup_id}",
+ repo_structure_pattern="{env_id}/{repo_id}",
+ filename_pattern="{name}_{timestamp}",
+ )
mock_config.get_config.return_value = mock_cfg
from src.dependencies import get_config_manager
@@ -202,8 +241,13 @@ class TestGetEnvironments:
"""GET /settings/environments"""
def test_success(self):
+ from src.core.config_models import Environment
mock_config = MagicMock()
- mock_config.get_environments.return_value = [_make_env()]
+ real_env = Environment(
+ id="env-1", name="Test Env", url="https://example.com",
+ username="admin", password="secret", stage="dev",
+ )
+ mock_config.get_environments.return_value = [real_env]
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
@@ -211,7 +255,7 @@ class TestGetEnvironments:
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
- assert data[0]["id"] == "env-1"
+ assert data[0]["id"] == "env-1""
# ── add_environment ──
diff --git a/backend/tests/api/test_storage.py b/backend/tests/api/test_storage.py
index 2dff014b..86fdbf59 100644
--- a/backend/tests/api/test_storage.py
+++ b/backend/tests/api/test_storage.py
@@ -68,7 +68,7 @@ class TestListFiles:
mock_loader.get_plugin.return_value = mock_plugin
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)
+ mock_plugin.list_files.assert_called_once_with("backups", "/test", True)
def test_plugin_not_loaded(self):
client, mock_loader = _make_client()
@@ -85,7 +85,7 @@ class TestUploadFile:
mock_plugin = MagicMock()
mock_plugin.save_file = AsyncMock(return_value={"name": "test.txt"})
mock_loader.get_plugin.return_value = mock_plugin
- resp = client.post(f"{P}/upload", data={"category": "BACKUPS"}, files={"file": ("test.txt", b"hello")})
+ resp = client.post(f"{P}/upload", data={"category": "backups"}, files={"file": ("test.txt", b"hello")})
assert resp.status_code == 201
def test_value_error(self):
@@ -93,13 +93,13 @@ class TestUploadFile:
mock_plugin = MagicMock()
mock_plugin.save_file = AsyncMock(side_effect=ValueError("bad"))
mock_loader.get_plugin.return_value = mock_plugin
- resp = client.post(f"{P}/upload", data={"category": "BACKUPS"}, files={"file": ("test.txt", b"data")})
+ resp = client.post(f"{P}/upload", data={"category": "backups"}, files={"file": ("test.txt", b"data")})
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.post(f"{P}/upload", data={"category": "BACKUPS"}, files={"file": ("test.txt", b"data")})
+ resp = client.post(f"{P}/upload", data={"category": "backups"}, files={"file": ("test.txt", b"data")})
assert resp.status_code == 500
diff --git a/backend/tests/api/test_tasks.py b/backend/tests/api/test_tasks.py
index 921a737c..3fdf0a7a 100644
--- a/backend/tests/api/test_tasks.py
+++ b/backend/tests/api/test_tasks.py
@@ -53,7 +53,16 @@ class TestCreateTask:
def test_success(self):
mock_tm = MagicMock()
- mock_tm.create_task = AsyncMock(return_value=MagicMock(id="task-1", plugin_id="backup", status="PENDING", params={}, created_at=None))
+ from src.core.task_manager import Task
+ mock_task = MagicMock(spec=Task)
+ mock_task.id = "task-1"
+ mock_task.plugin_id = "backup"
+ mock_task.status = "PENDING"
+ mock_task.params = {}
+ mock_task.created_at = None
+ mock_task.user_id = "admin"
+ mock_task.input_request = None
+ mock_tm.create_task = AsyncMock(return_value=mock_task)
from src.dependencies import get_task_manager
client = _make_client({get_task_manager: lambda: mock_tm})
@@ -132,7 +141,16 @@ class TestGetTask:
def test_success(self):
mock_tm = MagicMock()
- mock_tm.get_task.return_value = MagicMock(id="task-123", plugin_id="backup", status="PENDING", params={}, created_at=None)
+ from src.core.task_manager import Task
+ mock_task = MagicMock(spec=Task)
+ mock_task.id = "task-123"
+ mock_task.plugin_id = "backup"
+ mock_task.status = "PENDING"
+ mock_task.params = {}
+ mock_task.created_at = None
+ mock_task.user_id = "admin"
+ mock_task.input_request = None
+ mock_tm.get_task.return_value = mock_task
from src.dependencies import get_task_manager
client = _make_client({get_task_manager: lambda: mock_tm})
@@ -158,7 +176,7 @@ class TestGetTaskLogs:
def test_success(self):
mock_tm = MagicMock()
mock_tm.get_task.return_value = MagicMock(id="task-123")
- mock_tm.get_task_logs.return_value = [{"level": "INFO", "message": "test"}]
+ mock_tm.get_task_logs.return_value = [MagicMock(level="INFO", message="test", timestamp=None, source="test")]
from src.dependencies import get_task_manager
client = _make_client({get_task_manager: lambda: mock_tm})
@@ -266,7 +284,10 @@ class TestResolveTask:
def test_success(self):
mock_tm = MagicMock()
mock_tm.resolve_task = AsyncMock()
- mock_tm.get_task.return_value = MagicMock(id="task-123")
+ from src.core.task_manager import Task
+ mock_task = MagicMock(spec=Task)
+ mock_task.id = "task-123"
+ mock_tm.get_task.return_value = mock_task
from src.dependencies import get_task_manager
client = _make_client({get_task_manager: lambda: mock_tm})
@@ -291,7 +312,10 @@ class TestResumeTask:
def test_success(self):
mock_tm = MagicMock()
mock_tm.resume_task_with_password = MagicMock()
- mock_tm.get_task.return_value = MagicMock(id="task-123")
+ from src.core.task_manager import Task
+ mock_task = MagicMock(spec=Task)
+ mock_task.id = "task-123"
+ mock_tm.get_task.return_value = mock_task
from src.dependencies import get_task_manager
client = _make_client({get_task_manager: lambda: mock_tm})
diff --git a/backend/tests/api/test_translate_job_routes.py b/backend/tests/api/test_translate_job_routes.py
index 1f94cd7e..9f3ee63f 100644
--- a/backend/tests/api/test_translate_job_routes.py
+++ b/backend/tests/api/test_translate_job_routes.py
@@ -277,21 +277,40 @@ class TestCreateJobErrors:
# =============================================================================
def _mock_job(id="job-1", name="Test Job", **kw):
- """Create a mock TranslationJob ORM object."""
+ """Create a mock TranslationJob ORM object with all fields job_to_response needs."""
+ dt = __import__("datetime").datetime(2026, 1, 1)
j = MagicMock()
j.id = id
j.name = name
j.description = kw.get("description")
j.source_dialect = kw.get("source_dialect", "postgresql")
j.target_dialect = kw.get("target_dialect", "clickhouse")
+ j.database_dialect = kw.get("database_dialect")
+ j.source_datasource_id = kw.get("source_datasource_id")
+ j.source_table = kw.get("source_table")
+ j.target_schema = kw.get("target_schema")
+ j.target_table = kw.get("target_table")
j.source_key_cols = kw.get("source_key_cols", ["id"])
j.target_key_cols = kw.get("target_key_cols", ["id"])
j.translation_column = kw.get("translation_column", "name")
+ j.target_column = kw.get("target_column")
+ j.target_language_column = kw.get("target_language_column")
+ j.target_source_column = kw.get("target_source_column")
+ j.target_source_language_column = kw.get("target_source_language_column")
+ j.context_columns = kw.get("context_columns")
+ j.target_languages = kw.get("target_languages")
+ j.provider_id = kw.get("provider_id")
j.batch_size = kw.get("batch_size", 50)
+ j.disable_reasoning = kw.get("disable_reasoning", False)
j.upsert_strategy = kw.get("upsert_strategy", "MERGE")
j.status = kw.get("status", "DRAFT")
j.created_by = kw.get("created_by", "admin")
- j.created_at = kw.get("created_at", __import__("datetime").datetime(2026, 1, 1))
+ j.created_at = kw.get("created_at", dt)
+ j.updated_at = kw.get("updated_at")
+ j.environment_id = kw.get("environment_id")
+ j.target_database_id = kw.get("target_database_id")
+ j.insert_method = kw.get("insert_method", "sqllab")
+ j.connection_id = kw.get("connection_id")
return j
@@ -338,7 +357,7 @@ class TestUpdateJob:
"""Covers success path lines 141-142."""
mock_job = _mock_job(name="Updated Job")
mock_service = MagicMock()
- mock_service.update_job.return_value = mock_job
+ mock_service.update_job = AsyncMock(return_value=mock_job)
mock_service.get_job_dictionary_ids.return_value = ["dict-1"]
with patch(
@@ -367,6 +386,88 @@ class TestUpdateJob:
assert resp.status_code == 404
+class TestListJobsSuccess:
+ """GET /api/translate/jobs — success path (lines 52-66)."""
+
+ def test_list_jobs_success(self):
+ """Covers list_jobs success path including dict_ids loop."""
+ mock_job_1 = _mock_job(id="job-1", name="Job One")
+ mock_job_2 = _mock_job(id="job-2", name="Job Two")
+ mock_service = MagicMock()
+ mock_service.list_jobs.return_value = (2, [mock_job_1, mock_job_2])
+ mock_service.get_job_dictionary_ids.side_effect = [["dict-a"], ["dict-b"]]
+
+ from src.plugins.translate.service_utils import job_to_response
+ with patch(
+ "src.api.routes.translate._job_routes.TranslateJobService",
+ return_value=mock_service,
+ ):
+ client = _make_client()
+ resp = client.get("/api/translate/jobs")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert isinstance(data, list)
+ assert len(data) == 2
+ assert data[0]["id"] == "job-1"
+ assert data[1]["id"] == "job-2"
+ assert mock_service.get_job_dictionary_ids.call_count == 2
+
+
+class TestCreateJobSuccess:
+ """POST /api/translate/jobs — success path (lines 114-115)."""
+
+ def test_create_job_success(self):
+ """Covers create_job success path with dict_ids return."""
+ mock_job = _mock_job(id="job-new", name="New Job")
+ mock_service = MagicMock()
+ mock_service.create_job = AsyncMock(return_value=mock_job)
+ mock_service.get_job_dictionary_ids.return_value = ["dict-1"]
+
+ with patch(
+ "src.api.routes.translate._job_routes.TranslateJobService",
+ return_value=mock_service,
+ ):
+ client = _make_client()
+ resp = client.post("/api/translate/jobs", json={
+ "name": "New Job",
+ "source_dialect": "postgresql",
+ "target_dialect": "clickhouse",
+ })
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["id"] == "job-new"
+ assert data["name"] == "New Job"
+
+
+class TestDeleteJob:
+ """DELETE /api/translate/jobs/{job_id} — lines 162-167."""
+
+ def test_delete_job_success(self):
+ """Covers delete_job success (204)."""
+ mock_service = MagicMock()
+
+ with patch(
+ "src.api.routes.translate._job_routes.TranslateJobService",
+ return_value=mock_service,
+ ):
+ client = _make_client()
+ resp = client.delete("/api/translate/jobs/job-1")
+ assert resp.status_code == 204
+
+ def test_delete_job_404(self):
+ """Covers delete_job ValueError -> 404."""
+ mock_service = MagicMock()
+ mock_service.delete_job.side_effect = ValueError("Job not found")
+
+ with patch(
+ "src.api.routes.translate._job_routes.TranslateJobService",
+ return_value=mock_service,
+ ):
+ client = _make_client()
+ resp = client.delete("/api/translate/jobs/job-missing")
+ assert resp.status_code == 404
+
+
class TestDuplicateJob:
"""POST /api/translate/jobs/{job_id}/duplicate"""
diff --git a/backend/tests/api/test_translate_run_routes.py b/backend/tests/api/test_translate_run_routes.py
index 501c1e83..0d91ce65 100644
--- a/backend/tests/api/test_translate_run_routes.py
+++ b/backend/tests/api/test_translate_run_routes.py
@@ -231,6 +231,141 @@ class TestRetryInsert:
assert "Retry insert failed" in resp.text
+# =============================================================================
+# Background Execute — covers _background_execute inner function (lines 57-120)
+# =============================================================================
+
+class TestBackgroundExecute:
+ """Execute the _background_execute coroutine via real asyncio.create_task."""
+
+ def test_bg_run_not_found(self):
+ """Run not found in background: early return (lines 71-77)."""
+ mock_orch = MagicMock()
+ mock_orch.start_run.return_value = _mock_run()
+
+ mock_bg_db = MagicMock()
+ mock_bg_db.query.return_value.filter.return_value.first.return_value = None
+
+ with (
+ patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
+ return_value=mock_orch),
+ patch("src.api.routes.translate._run_routes.SessionLocal",
+ return_value=mock_bg_db),
+ patch("src.api.routes.translate._run_routes.seed_trace_id"),
+ ):
+ client = _make_client()
+ resp = client.post("/api/translate/jobs/job-1/run")
+ assert resp.status_code == 201
+ mock_bg_db.close.assert_called_once()
+
+ def test_bg_execute_success(self):
+ """Background execute succeeds (lines 65-85)."""
+ mock_run = _mock_run(status="COMPLETED")
+ mock_run.completed_at = __import__("datetime").datetime(2026, 1, 1)
+ mock_orch = MagicMock()
+ mock_orch.start_run.return_value = _mock_run()
+ mock_orch.execute_run = AsyncMock()
+
+ mock_bg_db = MagicMock()
+ # Two queries: first to find run, second to verify
+ mock_bg_db.query.return_value.filter.return_value.first.side_effect = [mock_run, mock_run]
+
+ with (
+ patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
+ return_value=mock_orch),
+ patch("src.api.routes.translate._run_routes.SessionLocal",
+ return_value=mock_bg_db),
+ patch("src.api.routes.translate._run_routes.seed_trace_id"),
+ patch("src.api.routes.translate._run_routes.get_trace_id",
+ return_value="trace-abc-123"),
+ ):
+ client = _make_client()
+ resp = client.post("/api/translate/jobs/job-1/run")
+ assert resp.status_code == 201
+ mock_orch.execute_run.assert_awaited_once()
+ mock_bg_db.close.assert_called_once()
+
+ def test_bg_execute_not_terminal_marks_failed(self):
+ """Run not in terminal state after commit: marked FAILED (lines 86-95)."""
+ mock_run_pending = _mock_run(status="RUNNING")
+ mock_orch = MagicMock()
+ mock_orch.start_run.return_value = _mock_run()
+ mock_orch.execute_run = AsyncMock()
+
+ mock_bg_db = MagicMock()
+ mock_bg_db.query.return_value.filter.return_value.first.side_effect = [mock_run_pending, mock_run_pending]
+
+ with (
+ patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
+ return_value=mock_orch),
+ patch("src.api.routes.translate._run_routes.SessionLocal",
+ return_value=mock_bg_db),
+ patch("src.api.routes.translate._run_routes.seed_trace_id"),
+ ):
+ client = _make_client()
+ resp = client.post("/api/translate/jobs/job-1/run")
+ assert resp.status_code == 201
+ # Run should have been marked FAILED
+ assert mock_run_pending.status == "FAILED"
+ mock_bg_db.commit.assert_called()
+ mock_bg_db.close.assert_called_once()
+
+ def test_bg_execute_exception_marks_failed(self):
+ """Exception in background marks run FAILED via fallback (lines 97-120)."""
+ mock_run = _mock_run()
+ mock_orch = MagicMock()
+ mock_orch.start_run.return_value = mock_run
+ mock_orch.execute_run = AsyncMock(side_effect=RuntimeError("LLM timeout"))
+
+ mock_bg_db = MagicMock()
+ mock_bg_db.query.return_value.filter.return_value.first.side_effect = [mock_run]
+
+ # Second session for fallback
+ mock_fb_db = MagicMock()
+ mock_fb_db.query.return_value.filter.return_value.first.return_value = mock_run
+
+ with (
+ patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
+ return_value=mock_orch),
+ patch("src.api.routes.translate._run_routes.SessionLocal",
+ side_effect=[mock_bg_db, mock_fb_db]),
+ patch("src.api.routes.translate._run_routes.seed_trace_id"),
+ ):
+ client = _make_client()
+ resp = client.post("/api/translate/jobs/job-1/run")
+ assert resp.status_code == 201
+ assert mock_run.status == "FAILED"
+ assert mock_run.error_message is not None
+ mock_fb_db.close.assert_called_once()
+ mock_bg_db.close.assert_called_once()
+
+ def test_bg_execute_exception_fallback_fails(self):
+ """Exception in background AND fallback: handled gracefully (lines 115-117)."""
+ mock_run = _mock_run()
+ mock_orch = MagicMock()
+ mock_orch.start_run.return_value = mock_run
+ mock_orch.execute_run = AsyncMock(side_effect=RuntimeError("LLM timeout"))
+
+ mock_bg_db = MagicMock()
+ mock_bg_db.query.return_value.filter.return_value.first.return_value = mock_run
+
+ # Fallback db also raises
+ bad_fb_db = MagicMock()
+ bad_fb_db.query.side_effect = RuntimeError("DB closed")
+
+ with (
+ patch("src.api.routes.translate._run_routes.TranslationOrchestrator",
+ return_value=mock_orch),
+ patch("src.api.routes.translate._run_routes.SessionLocal",
+ side_effect=[mock_bg_db, bad_fb_db]),
+ patch("src.api.routes.translate._run_routes.seed_trace_id"),
+ ):
+ client = _make_client()
+ resp = client.post("/api/translate/jobs/job-1/run")
+ assert resp.status_code == 201
+ mock_bg_db.close.assert_called_once()
+
+
# =============================================================================
# Cancel Run
# =============================================================================
diff --git a/backend/tests/api/test_validation_tasks_comprehensive.py b/backend/tests/api/test_validation_tasks_comprehensive.py
index c841c98e..23a3250b 100644
--- a/backend/tests/api/test_validation_tasks_comprehensive.py
+++ b/backend/tests/api/test_validation_tasks_comprehensive.py
@@ -10,6 +10,7 @@ os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests")
os.environ.setdefault("DEV_MODE", "true")
import sys
+from datetime import datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
@@ -29,7 +30,7 @@ def _make_client(overrides: dict | None = None) -> TestClient:
from src.schemas.auth import User, RoleSchema
app = FastAPI()
- app.include_router(router)
+ app.include_router(router, prefix="/validation-tasks")
mock_user = User(
id="admin-1", username="admin", email="admin@x.com",
@@ -57,7 +58,11 @@ class TestListTasks:
def test_success(self):
mock_svc = MagicMock()
- mock_svc.list_tasks.return_value = (1, [{"id": "task-1", "name": "Test"}])
+ mock_svc.list_tasks.return_value = (1, [{
+ "id": "task-1", "name": "Test", "environment_id": "env-1",
+ "is_active": True, "dashboard_ids": ["dash-1"],
+ "created_at": "2024-01-01T00:00:00",
+ }])
from src.api.routes.validation_tasks import _get_task_service
client = _make_client({_get_task_service: lambda: mock_svc})
@@ -99,8 +104,14 @@ class TestCreateTask:
}
def test_success(self):
+ from src.schemas.validation import ValidationTaskResponse
+
mock_svc = MagicMock()
- mock_svc.create_task.return_value = {"id": "task-new", "name": "New Task"}
+ mock_svc.create_task.return_value = ValidationTaskResponse(
+ id="task-new", name="New Task", environment_id="env-1",
+ is_active=True, dashboard_ids=["dash-1"],
+ created_at=datetime.now(),
+ )
mock_sched = MagicMock()
from src.api.routes.validation_tasks import _get_task_service
@@ -130,7 +141,11 @@ class TestGetTask:
def test_success(self):
mock_svc = MagicMock()
- mock_svc.get_task.return_value = {"id": "task-1", "name": "Test Task"}
+ mock_svc.get_task.return_value = {
+ "id": "task-1", "name": "Test Task", "environment_id": "env-1",
+ "is_active": True, "dashboard_ids": ["dash-1"],
+ "created_at": "2024-01-01T00:00:00",
+ }
from src.api.routes.validation_tasks import _get_task_service
client = _make_client({_get_task_service: lambda: mock_svc})
@@ -156,8 +171,14 @@ class TestUpdateTask:
UPDATE_PAYLOAD = {"name": "Updated Task"}
def test_success(self):
+ from src.schemas.validation import ValidationTaskResponse
+
mock_svc = MagicMock()
- mock_svc.update_task.return_value = {"id": "task-1", "name": "Updated Task"}
+ mock_svc.update_task.return_value = ValidationTaskResponse(
+ id="task-1", name="Updated Task", environment_id="env-1",
+ is_active=True, dashboard_ids=["dash-1"],
+ created_at=datetime.now(),
+ )
mock_sched = MagicMock()
from src.api.routes.validation_tasks import _get_task_service
@@ -187,7 +208,11 @@ class TestToggleTaskStatus:
def test_activate(self):
mock_svc = MagicMock()
- mock_svc.update_task.return_value = {"id": "task-1", "is_active": True}
+ mock_svc.update_task.return_value = {
+ "id": "task-1", "name": "Task", "environment_id": "env-1",
+ "is_active": True, "dashboard_ids": ["dash-1"],
+ "created_at": "2024-01-01T00:00:00",
+ }
mock_sched = MagicMock()
from src.api.routes.validation_tasks import _get_task_service
@@ -202,7 +227,11 @@ class TestToggleTaskStatus:
def test_deactivate(self):
mock_svc = MagicMock()
- mock_svc.update_task.return_value = {"id": "task-1", "is_active": False}
+ mock_svc.update_task.return_value = {
+ "id": "task-1", "name": "Task", "environment_id": "env-1",
+ "is_active": False, "dashboard_ids": ["dash-1"],
+ "created_at": "2024-01-01T00:00:00",
+ }
mock_sched = MagicMock()
from src.api.routes.validation_tasks import _get_task_service
@@ -239,7 +268,8 @@ class TestTriggerRun:
resp = client.post("/validation-tasks/task-1/run")
assert resp.status_code == 200
data = resp.json()
- assert data["spawned_task_id"] == "task-1"
+ # spawned_task_id comes from result["task_id"] (the mock returns "t-1")
+ assert data["spawned_task_id"] == "t-1"
assert data["status"] == "PENDING"
def test_value_error(self):
@@ -259,7 +289,9 @@ class TestListAllRuns:
def test_success(self):
mock_svc = MagicMock()
- mock_svc.list_all_runs.return_value = (1, [{"id": "run-1"}])
+ mock_svc.list_all_runs.return_value = (1, [{
+ "id": "run-1", "dashboard_id": "dash-1", "status": "SUCCESS",
+ }])
from src.api.routes.validation_tasks import _get_task_service
client = _make_client({_get_task_service: lambda: mock_svc})
@@ -377,7 +409,7 @@ class TestParseSupersetUrl:
mock_extractor.client = mock_client
from src.dependencies import get_config_manager
- with patch("src.api.routes.validation_tasks.SupersetContextExtractor", return_value=mock_extractor):
+ with patch("src.core.utils.superset_context_extractor.SupersetContextExtractor", return_value=mock_extractor):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.post("/validation-tasks/parse-url", json=self.PARSE_PAYLOAD)
assert resp.status_code == 200
@@ -400,7 +432,7 @@ class TestParseSupersetUrl:
mock_extractor.client = MagicMock()
from src.dependencies import get_config_manager
- with patch("src.api.routes.validation_tasks.SupersetContextExtractor", return_value=mock_extractor):
+ with patch("src.core.utils.superset_context_extractor.SupersetContextExtractor", return_value=mock_extractor):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.post("/validation-tasks/parse-url", json=self.PARSE_PAYLOAD)
assert resp.status_code == 200
@@ -423,7 +455,7 @@ class TestParseSupersetUrl:
mock_extractor.parse_superset_link = AsyncMock(side_effect=ValueError("Invalid URL"))
from src.dependencies import get_config_manager
- with patch("src.api.routes.validation_tasks.SupersetContextExtractor", return_value=mock_extractor):
+ with patch("src.core.utils.superset_context_extractor.SupersetContextExtractor", return_value=mock_extractor):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.post("/validation-tasks/parse-url", json=self.PARSE_PAYLOAD)
assert resp.status_code == 422
@@ -436,7 +468,7 @@ class TestParseSupersetUrl:
mock_extractor.parse_superset_link = AsyncMock(side_effect=RuntimeError("Network error"))
from src.dependencies import get_config_manager
- with patch("src.api.routes.validation_tasks.SupersetContextExtractor", return_value=mock_extractor):
+ with patch("src.core.utils.superset_context_extractor.SupersetContextExtractor", return_value=mock_extractor):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.post("/validation-tasks/parse-url", json=self.PARSE_PAYLOAD)
assert resp.status_code == 502
@@ -457,7 +489,7 @@ class TestLegacyLlmReportRedirect:
from src.core.database import get_db
client = _make_client({get_db: lambda: mock_db})
- resp = client.get("/validation-tasks/reports/llm/old-task")
+ resp = client.get("/validation-tasks/reports/llm/old-task", follow_redirects=False)
assert resp.status_code == 302
assert "/validation-tasks/policy-1/runs/run-1" in resp.headers["location"]
diff --git a/backend/tests/core/superset_client/test_client_datasets_edge.py b/backend/tests/core/superset_client/test_client_datasets_edge.py
new file mode 100644
index 00000000..f49269fb
--- /dev/null
+++ b/backend/tests/core/superset_client/test_client_datasets_edge.py
@@ -0,0 +1,243 @@
+# #region Test.SupersetClient.Datasets.Edge [C:3] [TYPE Module] [SEMANTICS test, superset, datasets, mixin, edge]
+# @BRIEF Edge case tests for SupersetDatasetsMixin — get_datasets_summary, get_dataset_linked_dashboard_count, get_dataset_detail edge cases.
+# @RELATION BINDS_TO -> [SupersetDatasetsMixin]
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
+
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+
+
+class TestGetDatasetsSummary:
+ @pytest.mark.asyncio
+ async def test_summary(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_datasets = AsyncMock(return_value=(2, [
+ {"id": 1, "table_name": "orders", "schema": "public", "database": {"database_name": "Main"}},
+ {"id": 2, "table_name": "users", "schema": "public"}, # no database key → fallback to Unknown
+ ]))
+ result = await mixin.get_datasets_summary()
+ assert len(result) == 2
+ assert result[0]["database"] == "Main"
+ assert result[1]["database"] == "Unknown"
+
+
+class TestGetDatasetLinkedDashboardCount:
+ @pytest.mark.asyncio
+ async def test_with_dashboards_in_response(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.client = AsyncMock()
+ mixin.client.request.return_value = {"dashboards": [{"id": 1}, {"id": 2}]}
+ count = await mixin.get_dataset_linked_dashboard_count(1)
+ assert count == 2
+
+ @pytest.mark.asyncio
+ async def test_with_result_key(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.client = AsyncMock()
+ mixin.client.request.return_value = {"result": {"dashboards": [{"id": 1}]}}
+ count = await mixin.get_dataset_linked_dashboard_count(1)
+ assert count == 1
+
+ @pytest.mark.asyncio
+ async def test_non_dict_response(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.client = AsyncMock()
+ mixin.client.request.return_value = "not_a_dict"
+ count = await mixin.get_dataset_linked_dashboard_count(1)
+ assert count == 0
+
+ @pytest.mark.asyncio
+ async def test_exception_returns_zero(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.client = AsyncMock()
+ mixin.client.request.side_effect = Exception("API error")
+ count = await mixin.get_dataset_linked_dashboard_count(1)
+ assert count == 0
+
+ @pytest.mark.asyncio
+ async def test_empty_dashboards(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.client = AsyncMock()
+ mixin.client.request.return_value = {"dashboards": []}
+ count = await mixin.get_dataset_linked_dashboard_count(1)
+ assert count == 0
+
+ @pytest.mark.asyncio
+ async def test_unknown_structure(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.client = AsyncMock()
+ mixin.client.request.return_value = {"other": "data"}
+ count = await mixin.get_dataset_linked_dashboard_count(1)
+ assert count == 0
+
+
+class TestGetDatasetDetail:
+ @pytest.mark.asyncio
+ async def test_column_with_none_id_skipped(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "result": {
+ "columns": [
+ {"id": None, "column_name": "skip_me"},
+ {"id": 2, "column_name": "keep_me"},
+ ],
+ "metrics": [],
+ "sql": "SELECT 1",
+ "database": {"id": 1, "backend": "postgresql"},
+ }
+ })
+ mixin.client = MagicMock()
+ mixin.client.request = AsyncMock(return_value={"dashboards": [{"id": 1, "dashboard_title": "Dash"}]})
+ detail = await mixin.get_dataset_detail(1)
+ assert len(detail["columns"]) == 1
+ assert detail["columns"][0]["name"] == "keep_me"
+
+ @pytest.mark.asyncio
+ async def test_metric_with_none_id_skipped(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "result": {
+ "columns": [],
+ "metrics": [
+ {"id": None, "metric_name": "skip"},
+ {"id": 5, "metric_name": "keep"},
+ ],
+ "sql": "",
+ "database": {"id": 1},
+ }
+ })
+ mixin.client = MagicMock()
+ mixin.client.request = AsyncMock(return_value={"dashboards": []})
+ detail = await mixin.get_dataset_detail(1)
+ assert len(detail["metrics"]) == 1
+ assert detail["metrics"][0]["metric_name"] == "keep"
+
+ @pytest.mark.asyncio
+ async def test_dashboard_with_none_id_skipped(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "result": {
+ "columns": [], "metrics": [], "sql": "",
+ "database": {"id": 1, "backend": "postgresql"},
+ }
+ })
+ mixin.client = MagicMock()
+ mixin.client.request = AsyncMock(return_value={
+ "dashboards": [{"id": None, "dashboard_title": "Skip"}]
+ })
+ detail = await mixin.get_dataset_detail(1)
+ assert len(detail["linked_dashboards"]) == 0
+
+ @pytest.mark.asyncio
+ async def test_dashboard_int_id(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "result": {
+ "columns": [], "metrics": [], "sql": "",
+ "database": {"id": 1, "backend": "postgresql"},
+ }
+ })
+ mixin.client = MagicMock()
+ mixin.client.request = AsyncMock(return_value={
+ "dashboards": [
+ {"id": 10, "dashboard_title": "Good"},
+ {"id": None, "dashboard_title": "Skip"},
+ ]
+ })
+ detail = await mixin.get_dataset_detail(1)
+ assert len(detail["linked_dashboards"]) == 1
+
+ @pytest.mark.asyncio
+ async def test_dashboards_from_result(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "result": {"columns": [], "metrics": [], "sql": "", "database": {"id": 1}}
+ })
+ mixin.client = MagicMock()
+ # Use the result key path
+ mixin.client.request = AsyncMock(return_value={
+ "result": {"dashboards": {"result": [{"id": 1}]}}
+ })
+ detail = await mixin.get_dataset_detail(1)
+ assert len(detail["linked_dashboards"]) == 1
+
+ @pytest.mark.asyncio
+ async def test_dashboards_api_exception(self):
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "result": {"columns": [], "metrics": [], "sql": "", "database": {"id": 1}}
+ })
+ mixin.client = MagicMock()
+ mixin.client.request.side_effect = Exception("API error")
+ detail = await mixin.get_dataset_detail(1)
+ assert detail["linked_dashboards"] == []
+
+ @pytest.mark.asyncio
+ async def test_result_not_in_response_structure(self):
+ """get_dataset returns data without 'result' key."""
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "id": 1, "columns": [{"id": 1, "column_name": "a"}],
+ "metrics": [], "sql": "", "database": {"id": 1},
+ })
+ mixin.client = MagicMock()
+ mixin.client.request = AsyncMock(return_value={"dashboards": []})
+ detail = await mixin.get_dataset_detail(1)
+ assert detail["id"] == 1
+ assert len(detail["columns"]) == 1
+
+ @pytest.mark.asyncio
+ async def test_as_bool_edge_cases(self):
+ """as_bool handles various input types."""
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "result": {
+ "columns": [
+ {"id": 1, "column_name": "c1", "is_dttm": "true", "is_active": "yes"},
+ {"id": 2, "column_name": "c2", "is_dttm": "false", "is_active": "no"},
+ ],
+ "metrics": [], "sql": "", "database": {"id": 1},
+ }
+ })
+ mixin.client = MagicMock()
+ mixin.client.request = AsyncMock(return_value={"dashboards": []})
+ detail = await mixin.get_dataset_detail(1)
+ cols = {c["name"]: c for c in detail["columns"]}
+ assert cols["c1"]["is_dttm"] is True
+ assert cols["c2"]["is_dttm"] is False
+
+ @pytest.mark.asyncio
+ async def test_database_backend_fallback(self):
+ """Uses engine as fallback for database_backend."""
+ from src.core.superset_client._datasets import SupersetDatasetsMixin
+ mixin = SupersetDatasetsMixin()
+ mixin.get_dataset = AsyncMock(return_value={
+ "result": {
+ "columns": [], "metrics": [], "sql": "",
+ "database": {"id": 1, "engine": "postgresql"},
+ }
+ })
+ mixin.client = MagicMock()
+ mixin.client.request = AsyncMock(return_value={"dashboards": []})
+ detail = await mixin.get_dataset_detail(1)
+ assert detail["database_backend"] == "postgresql"
+# #endregion Test.SupersetClient.Datasets.Edge
diff --git a/backend/tests/core/task_manager/test_graph.py b/backend/tests/core/task_manager/test_graph.py
new file mode 100644
index 00000000..1ca0a9be
--- /dev/null
+++ b/backend/tests/core/task_manager/test_graph.py
@@ -0,0 +1,203 @@
+# #region Test.TaskManager.Graph [C:3] [TYPE Module] [SEMANTICS test, task, graph, registry, crud]
+# @BRIEF Tests for TaskGraph — in-memory task registry with persistence.
+# @RELATION BINDS_TO -> [TaskGraphModule]
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
+
+from datetime import UTC, datetime
+from unittest.mock import MagicMock, AsyncMock
+
+import pytest
+
+from src.core.task_manager.graph import TaskGraph
+from src.core.task_manager.models import Task, TaskStatus
+from src.core.task_manager.persistence import TaskPersistenceService
+
+
+def _make_task(**kwargs):
+ """Helper to create a Task mock with required attributes."""
+ t = MagicMock(spec=Task)
+ t.started_at = kwargs.pop('started_at', None)
+ for k, v in kwargs.items():
+ setattr(t, k, v)
+ return t
+
+
+@pytest.fixture
+def persistence():
+ return MagicMock(spec=TaskPersistenceService)
+
+
+@pytest.fixture
+def graph(persistence):
+ return TaskGraph(persistence)
+
+
+class TestInit:
+ def test_empty(self, graph):
+ assert graph.tasks == {}
+ assert graph.task_futures == {}
+ assert graph.persistence_service is not None
+
+
+class TestLoadPersistedTasks:
+ def test_loads_new_tasks(self, graph, persistence):
+ task1 = _make_task(id="t1")
+ task2 = _make_task(id="t2")
+ persistence.load_tasks.return_value = [task1, task2]
+ graph.load_persisted_tasks(limit=50)
+ assert len(graph.tasks) == 2
+ assert graph.tasks["t1"] is task1
+ assert graph.tasks["t2"] is task2
+ persistence.load_tasks.assert_called_once_with(limit=50)
+
+ def test_skips_existing_tasks(self, graph, persistence):
+ existing = _make_task(id="t1")
+ graph.tasks["t1"] = existing
+ new_task = _make_task(id="t2")
+ loaded = _make_task(id="t1") # same ID as existing
+ persistence.load_tasks.return_value = [loaded, new_task]
+ graph.load_persisted_tasks()
+ assert graph.tasks["t1"] is existing # not overwritten
+ assert graph.tasks["t2"] is new_task
+
+
+class TestGetTask:
+ def test_found(self, graph):
+ task = _make_task(id="t1")
+ graph.tasks["t1"] = task
+ assert graph.get_task("t1") is task
+
+ def test_not_found(self, graph):
+ assert graph.get_task("nonexistent") is None
+
+
+class TestGetAllTasks:
+ def test_all(self, graph):
+ t1 = _make_task(id="a")
+ t2 = _make_task(id="b")
+ graph.tasks = {"a": t1, "b": t2}
+ all_tasks = graph.get_all_tasks()
+ assert len(all_tasks) == 2
+
+
+class TestGetTasks:
+ def test_no_filters(self, graph):
+ t1 = _make_task(id="a", started_at=None)
+ t2 = _make_task(id="b", started_at=None)
+ graph.tasks = {"a": t1, "b": t2}
+ result = graph.get_tasks(limit=10, offset=0)
+ assert len(result) <= 2
+
+ def test_filter_by_status(self, graph):
+ t1 = _make_task(id="a", status=TaskStatus.SUCCESS, started_at=None)
+ t2 = _make_task(id="b", status=TaskStatus.FAILED, started_at=None)
+ graph.tasks = {"a": t1, "b": t2}
+ result = graph.get_tasks(limit=10, offset=0, status=TaskStatus.SUCCESS)
+ assert len(result) == 1
+ assert result[0] is t1
+
+ def test_filter_by_plugin_ids(self, graph):
+ t1 = _make_task(id="a", plugin_id="plugin_a", started_at=None)
+ t2 = _make_task(id="b", plugin_id="plugin_b", started_at=None)
+ graph.tasks = {"a": t1, "b": t2}
+ result = graph.get_tasks(limit=10, offset=0, plugin_ids=["plugin_a"])
+ assert len(result) == 1
+ assert result[0] is t1
+
+ def test_filter_completed_only(self, graph):
+ t1 = _make_task(id="a", status=TaskStatus.SUCCESS, started_at=None)
+ t2 = _make_task(id="b", status=TaskStatus.RUNNING, started_at=None)
+ t3 = _make_task(id="c", status=TaskStatus.FAILED, started_at=None)
+ graph.tasks = {"a": t1, "b": t2, "c": t3}
+ result = graph.get_tasks(limit=10, offset=0, completed_only=True)
+ assert len(result) == 2
+
+ def test_sort_key_none_started_at(self, graph):
+ """Tasks with None started_at get sorted to the end."""
+ t1 = _make_task(id="a", started_at=None)
+ t2 = _make_task(id="b", started_at=datetime.now(UTC))
+ graph.tasks = {"a": t1, "b": t2}
+ result = graph.get_tasks(limit=10, offset=0)
+ assert result[0] is t2 # newest first
+
+ def test_sort_key_non_datetime(self, graph):
+ """started_at is not a datetime → treated as -inf."""
+ t1 = _make_task(id="a", started_at="not_a_datetime")
+ t2 = _make_task(id="b", started_at=datetime.now(UTC))
+ graph.tasks = {"a": t1, "b": t2}
+ result = graph.get_tasks(limit=10, offset=0)
+ assert result[0] is t2
+
+ def test_sort_key_naive_datetime(self, graph):
+ """Naive datetime gets UTC timezone."""
+ t1 = _make_task(id="a", started_at=datetime(2024, 1, 1, 12, 0, 0)) # no tz
+ t2 = _make_task(id="b", started_at=datetime(2024, 1, 1, 13, 0, 0, tzinfo=UTC))
+ graph.tasks = {"a": t1, "b": t2}
+ result = graph.get_tasks(limit=10, offset=0)
+ assert result[0] is t2
+
+
+class TestAddTask:
+ def test_adds(self, graph):
+ task = _make_task(id="t1")
+ graph.add_task(task)
+ assert graph.tasks["t1"] is task
+
+
+class TestRemoveTasks:
+ def test_removes_with_future(self, graph):
+ task = _make_task(id="t1")
+ future = MagicMock()
+ graph.tasks["t1"] = task
+ graph.task_futures["t1"] = future
+ count = graph.remove_tasks(["t1"])
+ assert count == 1
+ assert "t1" not in graph.tasks
+ assert "t1" not in graph.task_futures
+ future.cancel.assert_called_once()
+
+ def test_removes_without_future(self, graph, persistence):
+ task = _make_task(id="t1")
+ graph.tasks["t1"] = task
+ count = graph.remove_tasks(["t1"])
+ assert count == 1
+ persistence.delete_tasks.assert_called_once_with(["t1"])
+
+ def test_removes_empty_list(self, graph, persistence):
+ count = graph.remove_tasks([])
+ assert count == 0
+ persistence.delete_tasks.assert_not_called()
+
+
+class TestCreateFuture:
+ def test_creates(self, graph):
+ future = MagicMock()
+ graph.create_future("t1", future)
+ assert graph.task_futures["t1"] is future
+
+
+class TestResolveFuture:
+ def test_resolves(self, graph):
+ future = MagicMock()
+ graph.task_futures["t1"] = future
+ graph.resolve_future("t1", result=True)
+ future.set_result.assert_called_once_with(True)
+ assert "t1" not in graph.task_futures
+
+ def test_resolve_nonexistent(self, graph):
+ graph.resolve_future("nonexistent") # should not raise
+
+
+class TestRemoveFuture:
+ def test_removes(self, graph):
+ future = MagicMock()
+ graph.task_futures["t1"] = future
+ graph.remove_future("t1")
+ assert "t1" not in graph.task_futures
+
+ def test_remove_nonexistent(self, graph):
+ graph.remove_future("nonexistent") # should not raise
+# #endregion Test.TaskManager.Graph
diff --git a/backend/tests/plugins/translate/test_orchestrator_exec.py b/backend/tests/plugins/translate/test_orchestrator_exec.py
index d6135e32..6fc1412d 100644
--- a/backend/tests/plugins/translate/test_orchestrator_exec.py
+++ b/backend/tests/plugins/translate/test_orchestrator_exec.py
@@ -211,4 +211,16 @@ class TestTranslationExecutionEngine:
assert len(result) == 1
assert "en" in result
+
+ def test_init_language_stats_string_language_not_list(self):
+ """String target_languages (not a list) is wrapped in [str()] (line 108)."""
+ engine, db, config, event_log = self._make_engine()
+ run = self._make_run()
+ # target_languages as a plain string (not a list) — line 107 isinstance check fails -> line 108
+ job = self._make_job(target_languages="ru")
+
+ result = engine._init_language_stats(run, job)
+
+ assert len(result) == 1
+ assert "ru" in result
# #endregion Test.OrchestratorExec
diff --git a/backend/tests/plugins/translate/test_preview_session_serializer.py b/backend/tests/plugins/translate/test_preview_session_serializer.py
new file mode 100644
index 00000000..5c2280ff
--- /dev/null
+++ b/backend/tests/plugins/translate/test_preview_session_serializer.py
@@ -0,0 +1,126 @@
+# #region Test.PreviewSessionSerializer [C:2] [TYPE Module] [SEMANTICS test, translate, preview, serialize]
+# @BRIEF Tests for preview_session_serializer.py — serialize_preview_record.
+# @RELATION BINDS_TO -> [preview_session_serializer]
+# @TEST_CONTRACT: serialize_preview_record -> dict | serializes TranslationPreviewRecord
+# @TEST_EDGE: empty_languages
+# @TEST_EDGE: no_source_language_detected
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
+
+import os
+os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
+os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
+os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
+
+from unittest.mock import MagicMock
+
+from src.plugins.translate.preview_session_serializer import serialize_preview_record
+
+
+def _make_language(language_code="ru", **kwargs):
+ lang = MagicMock()
+ lang.language_code = language_code
+ lang.source_language_detected = kwargs.get("source_language_detected", "en")
+ lang.translated_value = kwargs.get("translated_value", "привет")
+ lang.final_value = kwargs.get("final_value", "привет")
+ lang.user_edit = kwargs.get("user_edit", None)
+ lang.status = kwargs.get("status", "translated")
+ lang.needs_review = kwargs.get("needs_review", False)
+ return lang
+
+
+def _make_record(**kwargs):
+ r = MagicMock(spec=object)
+ r.id = kwargs.get("id", "rec-1")
+ r.source_sql = kwargs.get("source_sql", "SELECT * FROM t")
+ r.target_sql = kwargs.get("target_sql", "SELECT * FROM t_translated")
+ r.source_object_type = kwargs.get("source_object_type", "table")
+ r.source_object_id = kwargs.get("source_object_id", "obj-1")
+ r.source_object_name = kwargs.get("source_object_name", "my_table")
+ r.status = kwargs.get("status", "done")
+ r.feedback = kwargs.get("feedback", None)
+ r.languages = kwargs.get("languages", [])
+ return r
+
+
+class TestSerializePreviewRecord:
+ """serialize_preview_record — serialize TranslationPreviewRecord."""
+
+ def test_basic_serialization(self):
+ """Full record with languages returns all fields."""
+ lang_ru = _make_language(language_code="ru", final_value="привет")
+ lang_de = _make_language(language_code="de", final_value="hallo")
+ record = _make_record(languages=[lang_ru, lang_de])
+
+ result = serialize_preview_record(record)
+
+ assert result["id"] == "rec-1"
+ assert result["source_sql"] == "SELECT * FROM t"
+ assert result["target_sql"] == "SELECT * FROM t_translated"
+ assert result["source_object_type"] == "table"
+ assert result["source_object_id"] == "obj-1"
+ assert result["source_object_name"] == "my_table"
+ assert result["status"] == "done"
+ assert result["feedback"] is None
+ assert result["needs_review"] is False
+ assert len(result["languages"]) == 2
+ assert result["languages"][0]["language_code"] == "ru"
+ assert result["languages"][0]["final_value"] == "привет"
+ assert result["languages"][1]["language_code"] == "de"
+
+ def test_empty_languages(self):
+ """Record with no languages returns empty languages list."""
+ record = _make_record(languages=[])
+
+ result = serialize_preview_record(record)
+
+ assert result["languages"] == []
+ assert result["source_language_detected"] is None
+ assert result["needs_review"] is False
+
+ def test_languages_is_none(self):
+ """Record with None languages returns empty list."""
+ record = _make_record(languages=None)
+
+ result = serialize_preview_record(record)
+
+ assert result["languages"] == []
+
+ def test_no_source_language_detected(self):
+ """Language without source_language_detected returns None."""
+ lang = _make_language(source_language_detected=None)
+ record = _make_record(languages=[lang])
+
+ result = serialize_preview_record(record)
+
+ assert result["source_language_detected"] is None
+
+ def test_source_language_detected_present(self):
+ """Language with source_language_detected returns it."""
+ lang = _make_language(source_language_detected="fr")
+ record = _make_record(languages=[lang])
+
+ result = serialize_preview_record(record)
+
+ assert result["source_language_detected"] == "fr"
+
+ def test_needs_review_from_language(self):
+ """needs_review comes from first language entry."""
+ lang = _make_language(needs_review=True)
+ record = _make_record(languages=[lang])
+
+ result = serialize_preview_record(record)
+
+ assert result["needs_review"] is True
+
+ def test_user_edit_present(self):
+ """user_edit field is included when present."""
+ lang = _make_language(user_edit="пользовательский перевод")
+ record = _make_record(languages=[lang])
+
+ result = serialize_preview_record(record)
+
+ assert result["languages"][0]["user_edit"] == "пользовательский перевод"
+# #endregion Test.PreviewSessionSerializer
diff --git a/backend/tests/schemas/test_dataset_review.py b/backend/tests/schemas/test_dataset_review.py
new file mode 100644
index 00000000..48f0ec01
--- /dev/null
+++ b/backend/tests/schemas/test_dataset_review.py
@@ -0,0 +1,510 @@
+# #region Test.Schemas.DatasetReview [C:2] [TYPE Module] [SEMANTICS test,schema,dataset,review]
+# @BRIEF Tests for schemas/dataset_review.py and dataset_review_pkg/* — DTOs and composites.
+# @RELATION BINDS_TO -> [DatasetReviewSchemas]
+# @TEST_EDGE: missing_field -> optional fields default correctly
+# @TEST_EDGE: invalid_type -> Pydantic validation rejects bad types
+# @TEST_EDGE: serialization -> round-trip via model_dump/model_validate
+
+from datetime import datetime, timezone
+import pytest
+
+# ── DTOs ────────────────────────────────────────────────────
+
+
+class TestSessionCollaboratorDto:
+ """SessionCollaboratorDto — from dataset_review_pkg._dtos."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import SessionCollaboratorDto
+ from src.models.dataset_review import SessionCollaboratorRole
+
+ ts = datetime.now(timezone.utc)
+ dto = SessionCollaboratorDto(
+ user_id="u-1",
+ role=SessionCollaboratorRole.VIEWER,
+ added_at=ts,
+ )
+ assert dto.user_id == "u-1"
+ assert dto.role == SessionCollaboratorRole.VIEWER
+
+ def test_serialize_roundtrip(self):
+ from src.schemas.dataset_review_pkg._dtos import SessionCollaboratorDto
+ from src.models.dataset_review import SessionCollaboratorRole
+
+ ts = datetime.now(timezone.utc)
+ dto = SessionCollaboratorDto(user_id="u-1", role=SessionCollaboratorRole.REVIEWER, added_at=ts)
+ data = dto.model_dump()
+ restored = SessionCollaboratorDto.model_validate(data)
+ assert restored.user_id == "u-1"
+ assert restored.role == "reviewer"
+
+ def test_from_attributes(self):
+ from src.schemas.dataset_review_pkg._dtos import SessionCollaboratorDto
+ from src.models.dataset_review import SessionCollaboratorRole
+
+ ts = datetime.now(timezone.utc)
+
+ class FakeORM:
+ user_id = "u-1"
+ role = SessionCollaboratorRole.APPROVER
+ added_at = ts
+ dto = SessionCollaboratorDto.model_validate(FakeORM())
+ assert dto.user_id == "u-1"
+
+
+class TestDatasetProfileDto:
+ """DatasetProfileDto — full profile DTO."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import DatasetProfileDto
+ from src.models.dataset_review import BusinessSummarySource, ConfidenceState
+
+ ts = datetime.now(timezone.utc)
+ dto = DatasetProfileDto(
+ profile_id="p-1",
+ session_id="s-1",
+ dataset_name="sales",
+ business_summary="Sales data",
+ business_summary_source=BusinessSummarySource.IMPORTED,
+ is_sqllab_view=False,
+ confidence_state=ConfidenceState.CONFIRMED,
+ has_blocking_findings=False,
+ has_warning_findings=False,
+ manual_summary_locked=False,
+ created_at=ts,
+ updated_at=ts,
+ )
+ assert dto.profile_id == "p-1"
+ assert dto.completeness_score is None
+
+ def test_serialize_roundtrip(self):
+ from src.schemas.dataset_review_pkg._dtos import DatasetProfileDto
+ from src.models.dataset_review import BusinessSummarySource, ConfidenceState
+
+ ts = datetime.now(timezone.utc)
+ dto = DatasetProfileDto(
+ profile_id="p-1", session_id="s-1", dataset_name="sales",
+ business_summary="Sales data", business_summary_source=BusinessSummarySource.INFERRED,
+ is_sqllab_view=False, confidence_state=ConfidenceState.MIXED,
+ has_blocking_findings=False, has_warning_findings=False,
+ manual_summary_locked=False, created_at=ts, updated_at=ts,
+ )
+ data = dto.model_dump()
+ restored = DatasetProfileDto.model_validate(data)
+ assert restored.profile_id == "p-1"
+
+
+class TestValidationFindingDto:
+ """ValidationFindingDto — finding with resolution tracking."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import ValidationFindingDto
+ from src.models.dataset_review import FindingArea, FindingSeverity, ResolutionState
+
+ ts = datetime.now(timezone.utc)
+ dto = ValidationFindingDto(
+ finding_id="f-1",
+ session_id="s-1",
+ area=FindingArea.DATASET_PROFILE,
+ severity=FindingSeverity.BLOCKING,
+ code="DQ001",
+ title="Null values",
+ message="Found null values",
+ resolution_state=ResolutionState.OPEN,
+ created_at=ts,
+ )
+ assert dto.finding_id == "f-1"
+ assert dto.resolved_at is None
+
+ def test_serialize_roundtrip(self):
+ from src.schemas.dataset_review_pkg._dtos import ValidationFindingDto
+ from src.models.dataset_review import FindingArea, FindingSeverity, ResolutionState
+
+ ts = datetime.now(timezone.utc)
+ dto = ValidationFindingDto(
+ finding_id="f-1", session_id="s-1", area=FindingArea.SOURCE_INTAKE,
+ severity=FindingSeverity.WARNING, code="DQ002", title="Duplicates",
+ message="Found duplicates", resolution_state=ResolutionState.RESOLVED,
+ resolved_at=ts, created_at=ts,
+ )
+ data = dto.model_dump()
+ restored = ValidationFindingDto.model_validate(data)
+ assert restored.code == "DQ002"
+
+
+class TestSemanticSourceDto:
+ """SemanticSourceDto — source with trust level."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import SemanticSourceDto
+ from src.models.dataset_review import SemanticSourceStatus, SemanticSourceType, TrustLevel
+
+ ts = datetime.now(timezone.utc)
+ dto = SemanticSourceDto(
+ source_id="s-1", session_id="ses-1",
+ source_type=SemanticSourceType.REFERENCE_DATASET,
+ source_ref="ref1", source_version="1.0",
+ display_name="Reference Dataset",
+ trust_level=TrustLevel.TRUSTED,
+ status=SemanticSourceStatus.AVAILABLE,
+ created_at=ts,
+ )
+ assert dto.source_id == "s-1"
+ assert dto.schema_overlap_score is None
+
+ def test_serialize_roundtrip(self):
+ from src.schemas.dataset_review_pkg._dtos import SemanticSourceDto
+ from src.models.dataset_review import SemanticSourceStatus, SemanticSourceType, TrustLevel
+
+ ts = datetime.now(timezone.utc)
+ dto = SemanticSourceDto(
+ source_id="s-1", session_id="ses-1",
+ source_type=SemanticSourceType.CONNECTED_DICTIONARY,
+ source_ref="ref1", source_version="1.0",
+ display_name="Dictionary", trust_level=TrustLevel.RECOMMENDED,
+ status=SemanticSourceStatus.SELECTED, created_at=ts,
+ )
+ data = dto.model_dump()
+ restored = SemanticSourceDto.model_validate(data)
+ assert restored.trust_level == "recommended"
+
+
+class TestSemanticCandidateDto:
+ """SemanticCandidateDto — candidate with match type."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import SemanticCandidateDto
+ from src.models.dataset_review import CandidateMatchType, CandidateStatus
+
+ ts = datetime.now(timezone.utc)
+ dto = SemanticCandidateDto(
+ candidate_id="c-1", field_id="f-1",
+ candidate_rank=1, match_type=CandidateMatchType.EXACT,
+ confidence_score=0.95, status=CandidateStatus.ACCEPTED,
+ created_at=ts,
+ )
+ assert dto.confidence_score == 0.95
+ assert dto.source_id is None
+
+ def test_serialize_roundtrip(self):
+ from src.schemas.dataset_review_pkg._dtos import SemanticCandidateDto
+ from src.models.dataset_review import CandidateMatchType, CandidateStatus
+
+ ts = datetime.now(timezone.utc)
+ dto = SemanticCandidateDto(
+ candidate_id="c-1", field_id="f-1", candidate_rank=1,
+ match_type=CandidateMatchType.FUZZY, confidence_score=0.75,
+ status=CandidateStatus.PROPOSED, created_at=ts,
+ )
+ data = dto.model_dump()
+ restored = SemanticCandidateDto.model_validate(data)
+ assert restored.status == "proposed"
+
+
+class TestSemanticFieldEntryDto:
+ """SemanticFieldEntryDto — field with nested candidates."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import SemanticFieldEntryDto
+ from src.models.dataset_review import FieldKind, FieldProvenance
+
+ ts = datetime.now(timezone.utc)
+ dto = SemanticFieldEntryDto(
+ field_id="f-1", session_id="s-1",
+ field_name="amount", field_kind=FieldKind.METRIC,
+ provenance=FieldProvenance.FUZZY_INFERRED,
+ is_locked=False, has_conflict=False, needs_review=False,
+ last_changed_by="admin", created_at=ts, updated_at=ts,
+ )
+ assert dto.candidates == []
+
+ def test_with_candidates(self):
+ from src.schemas.dataset_review_pkg._dtos import SemanticCandidateDto, SemanticFieldEntryDto
+ from src.models.dataset_review import CandidateMatchType, CandidateStatus, FieldKind, FieldProvenance
+
+ ts = datetime.now(timezone.utc)
+ cand = SemanticCandidateDto(
+ candidate_id="c-1", field_id="f-1", candidate_rank=1,
+ match_type=CandidateMatchType.EXACT, confidence_score=0.99,
+ status=CandidateStatus.ACCEPTED, created_at=ts,
+ )
+ dto = SemanticFieldEntryDto(
+ field_id="f-1", session_id="s-1", field_name="revenue",
+ field_kind=FieldKind.METRIC, provenance=FieldProvenance.DICTIONARY_EXACT,
+ is_locked=True, has_conflict=False, needs_review=False,
+ last_changed_by="admin", candidates=[cand],
+ created_at=ts, updated_at=ts,
+ )
+ assert len(dto.candidates) == 1
+ assert dto.candidates[0].confidence_score == 0.99
+
+
+class TestImportedFilterDto:
+ """ImportedFilterDto — filter with raw_value and confidence."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import ImportedFilterDto
+ from src.models.dataset_review import FilterConfidenceState, FilterRecoveryStatus, FilterSource
+
+ ts = datetime.now(timezone.utc)
+ dto = ImportedFilterDto(
+ filter_id="fl-1", session_id="s-1",
+ filter_name="region", raw_value="EMEA",
+ source=FilterSource.SUPERSET_URL,
+ confidence_state=FilterConfidenceState.CONFIRMED,
+ requires_confirmation=False,
+ recovery_status=FilterRecoveryStatus.RECOVERED,
+ created_at=ts, updated_at=ts,
+ )
+ assert dto.filter_name == "region"
+ assert dto.normalized_value is None
+
+ def test_with_raw_value_masked(self):
+ from src.schemas.dataset_review_pkg._dtos import ImportedFilterDto
+ from src.models.dataset_review import FilterConfidenceState, FilterRecoveryStatus, FilterSource
+
+ ts = datetime.now(timezone.utc)
+ dto = ImportedFilterDto(
+ filter_id="fl-1", session_id="s-1",
+ filter_name="region", raw_value="***", raw_value_masked=True,
+ source=FilterSource.SUPERSET_NATIVE,
+ confidence_state=FilterConfidenceState.IMPORTED,
+ requires_confirmation=True,
+ recovery_status=FilterRecoveryStatus.PARTIAL,
+ created_at=ts, updated_at=ts,
+ )
+ assert dto.raw_value_masked is True
+ assert dto.requires_confirmation is True
+
+
+class TestTemplateVariableDto:
+ """TemplateVariableDto — variable with mapping status."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import TemplateVariableDto
+ from src.models.dataset_review import MappingStatus, VariableKind
+
+ ts = datetime.now(timezone.utc)
+ dto = TemplateVariableDto(
+ variable_id="v-1", session_id="s-1",
+ variable_name="country", expression_source="{{ country }}",
+ variable_kind=VariableKind.NATIVE_FILTER,
+ is_required=True, mapping_status=MappingStatus.UNMAPPED,
+ created_at=ts, updated_at=ts,
+ )
+ assert dto.variable_name == "country"
+ assert dto.default_value is None
+
+
+class TestExecutionMappingDto:
+ """ExecutionMappingDto — mapping with approval state."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._dtos import ExecutionMappingDto
+ from src.models.dataset_review import ApprovalState, MappingMethod
+
+ ts = datetime.now(timezone.utc)
+ dto = ExecutionMappingDto(
+ mapping_id="m-1", session_id="s-1",
+ filter_id="f-1", variable_id="v-1",
+ mapping_method=MappingMethod.DIRECT_MATCH,
+ raw_input_value="EMEA",
+ requires_explicit_approval=False,
+ approval_state=ApprovalState.NOT_REQUIRED,
+ created_at=ts, updated_at=ts,
+ )
+ assert dto.mapping_method == "direct_match"
+ assert dto.effective_value is None
+
+
+# ── Composites ──────────────────────────────────────────────
+
+
+class TestClarificationOptionDto:
+ """ClarificationOptionDto — option for clarification question."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._composites import ClarificationOptionDto
+
+ dto = ClarificationOptionDto(
+ option_id="o-1", question_id="q-1",
+ label="Yes", value="yes",
+ is_recommended=True, display_order=1,
+ )
+ assert dto.label == "Yes"
+ assert dto.is_recommended is True
+
+
+class TestClarificationAnswerDto:
+ """ClarificationAnswerDto — answer with feedback."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._composites import ClarificationAnswerDto
+ from src.models.dataset_review import AnswerKind
+
+ ts = datetime.now(timezone.utc)
+ dto = ClarificationAnswerDto(
+ answer_id="a-1", question_id="q-1",
+ answer_kind=AnswerKind.SELECTED,
+ answered_by_user_id="u-1",
+ created_at=ts,
+ )
+ assert dto.impact_summary is None
+
+
+class TestClarificationQuestionDto:
+ """ClarificationQuestionDto — question with nested options and answer."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._composites import ClarificationQuestionDto
+ from src.models.dataset_review import QuestionState
+
+ ts = datetime.now(timezone.utc)
+ dto = ClarificationQuestionDto(
+ question_id="q-1", clarification_session_id="cs-1",
+ topic_ref="topic1", question_text="What?",
+ why_it_matters="Need to know", priority=1,
+ state=QuestionState.OPEN, created_at=ts, updated_at=ts,
+ )
+ assert dto.options == []
+ assert dto.answer is None
+
+
+class TestClarificationSessionDto:
+ """ClarificationSessionDto — session with nested questions."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._composites import ClarificationSessionDto
+ from src.models.dataset_review import ClarificationStatus
+
+ ts = datetime.now(timezone.utc)
+ dto = ClarificationSessionDto(
+ clarification_session_id="cs-1", session_id="s-1",
+ status=ClarificationStatus.ACTIVE,
+ resolved_count=0, remaining_count=5,
+ started_at=ts, updated_at=ts,
+ )
+ assert dto.questions == []
+
+
+class TestCompiledPreviewDto:
+ """CompiledPreviewDto — preview with fingerprint."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._composites import CompiledPreviewDto
+ from src.models.dataset_review import PreviewStatus
+
+ ts = datetime.now(timezone.utc)
+ dto = CompiledPreviewDto(
+ preview_id="p-1", session_id="s-1",
+ preview_status=PreviewStatus.READY,
+ preview_fingerprint="abc123", compiled_by="admin",
+ compiled_at=ts, created_at=ts,
+ )
+ assert dto.error_code is None
+
+
+class TestDatasetRunContextDto:
+ """DatasetRunContextDto — run context with launch audit."""
+
+ def test_minimal(self):
+ from src.schemas.dataset_review_pkg._composites import DatasetRunContextDto
+ from src.models.dataset_review import LaunchStatus
+
+ ts = datetime.now(timezone.utc)
+ dto = DatasetRunContextDto(
+ run_context_id="rc-1", session_id="s-1",
+ dataset_ref="ds1", environment_id="env-1",
+ preview_id="p-1", sql_lab_session_ref="sql1",
+ effective_filters={}, template_params={},
+ approved_mapping_ids=[], semantic_decision_refs=[],
+ open_warning_refs=[],
+ launch_status=LaunchStatus.SUCCESS,
+ created_at=ts,
+ )
+ assert dto.launch_status == "success"
+
+
+class TestSessionSummary:
+ """SessionSummary — lightweight session DTO."""
+
+ def test_minimal(self):
+ from src.models.dataset_review import ReadinessState, RecommendedAction, SessionPhase, SessionStatus
+ from src.schemas.dataset_review_pkg._composites import SessionSummary
+
+ ts = datetime.now(timezone.utc)
+ dto = SessionSummary(
+ session_id="s-1", user_id="u-1",
+ environment_id="env-1", source_kind="dashboard",
+ source_input="42", dataset_ref="ds1",
+ readiness_state=ReadinessState.EMPTY,
+ recommended_action=RecommendedAction.IMPORT_FROM_SUPERSET,
+ status=SessionStatus.ACTIVE,
+ current_phase=SessionPhase.INTAKE,
+ created_at=ts, updated_at=ts, last_activity_at=ts,
+ )
+ assert dto.session_id == "s-1"
+
+
+class TestSessionDetail:
+ """SessionDetail — full session with nested aggregates."""
+
+ def test_minimal(self):
+ from src.models.dataset_review import ReadinessState, RecommendedAction, SessionPhase, SessionStatus
+ from src.schemas.dataset_review_pkg._composites import SessionDetail
+
+ ts = datetime.now(timezone.utc)
+ dto = SessionDetail(
+ session_id="s-1", user_id="u-1",
+ environment_id="env-1", source_kind="dashboard",
+ source_input="42", dataset_ref="ds1",
+ readiness_state=ReadinessState.EMPTY,
+ recommended_action=RecommendedAction.IMPORT_FROM_SUPERSET,
+ status=SessionStatus.ACTIVE,
+ current_phase=SessionPhase.INTAKE,
+ created_at=ts, updated_at=ts, last_activity_at=ts,
+ )
+ assert dto.collaborators == []
+ assert dto.profile is None
+ assert dto.findings == []
+ assert dto.semantic_sources == []
+ assert dto.semantic_fields == []
+
+ def test_with_nested(self):
+ from src.models.dataset_review import (
+ BusinessSummarySource, ConfidenceState, ReadinessState, RecommendedAction,
+ SessionCollaboratorRole, SessionPhase, SessionStatus,
+ )
+ from src.schemas.dataset_review_pkg._composites import SessionDetail
+ from src.schemas.dataset_review_pkg._dtos import DatasetProfileDto, SessionCollaboratorDto
+
+ ts = datetime.now(timezone.utc)
+ collab = SessionCollaboratorDto(user_id="u-1", role=SessionCollaboratorRole.VIEWER, added_at=ts)
+ profile = DatasetProfileDto(
+ profile_id="p-1", session_id="s-1", dataset_name="sales",
+ business_summary="Sales data",
+ business_summary_source=BusinessSummarySource.IMPORTED,
+ is_sqllab_view=False, confidence_state=ConfidenceState.CONFIRMED,
+ has_blocking_findings=False, has_warning_findings=False,
+ manual_summary_locked=False, created_at=ts, updated_at=ts,
+ )
+ dto = SessionDetail(
+ session_id="s-1", user_id="u-1", environment_id="env-1",
+ source_kind="dashboard", source_input="42", dataset_ref="ds1",
+ readiness_state=ReadinessState.EMPTY,
+ recommended_action=RecommendedAction.IMPORT_FROM_SUPERSET,
+ status=SessionStatus.ACTIVE, current_phase=SessionPhase.INTAKE,
+ created_at=ts, updated_at=ts, last_activity_at=ts,
+ collaborators=[collab], profile=profile,
+ )
+ assert len(dto.collaborators) == 1
+ assert dto.profile is not None
+ assert dto.profile.dataset_name == "sales"
+
+ def test_facade_reimport(self):
+ """Verify dataset_review.py facade re-exports work."""
+ from src.schemas.dataset_review import SessionDetail, SessionSummary, DatasetProfileDto
+ assert SessionDetail is not None
+ assert SessionSummary is not None
+ assert DatasetProfileDto is not None
+# #endregion Test.Schemas.DatasetReview
diff --git a/backend/tests/services/dataset_review/test_session_repository.py b/backend/tests/services/dataset_review/test_session_repository.py
new file mode 100644
index 00000000..e6ebd426
--- /dev/null
+++ b/backend/tests/services/dataset_review/test_session_repository.py
@@ -0,0 +1,241 @@
+# #region Test.DatasetReview.SessionRepository [C:4] [TYPE Module] [SEMANTICS test, session, repository, dataset, review]
+# @BRIEF Tests for DatasetReviewSessionRepository — persistence and retrieval.
+# @RELATION BINDS_TO -> [DatasetReviewSessionRepository]
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
+
+from datetime import UTC, datetime
+from unittest.mock import MagicMock, patch
+
+import pytest
+from sqlalchemy.orm.exc import StaleDataError
+from sqlalchemy.orm import Session
+
+from src.services.dataset_review.repositories.session_repository import (
+ DatasetReviewSessionRepository,
+ DatasetReviewSessionVersionConflictError,
+)
+from src.models.dataset_review import (
+ DatasetReviewSession,
+ DatasetProfile,
+ ValidationFinding,
+ CompiledPreview,
+ DatasetRunContext,
+ ImportedFilter,
+ TemplateVariable,
+ ExecutionMapping,
+ SessionCollaborator,
+ SemanticFieldEntry,
+ ClarificationSession,
+ ClarificationQuestion,
+)
+
+
+@pytest.fixture
+def mock_db():
+ return MagicMock(spec=Session)
+
+
+@pytest.fixture
+def repo(mock_db):
+ return DatasetReviewSessionRepository(mock_db)
+
+
+class TestInit:
+ def test_creates_event_logger(self, mock_db):
+ repo = DatasetReviewSessionRepository(mock_db)
+ assert repo.db is mock_db
+ assert repo.event_logger is not None
+
+
+class TestGetOwnedSession:
+ def test_found(self, repo, mock_db):
+ mock_session = MagicMock(spec=DatasetReviewSession)
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_session
+ result = repo._get_owned_session("s1", "u1")
+ assert result is mock_session
+
+ def test_not_found_raises(self, repo, mock_db):
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+ with pytest.raises(ValueError, match="Session not found"):
+ repo._get_owned_session("s1", "u1")
+
+
+class TestCreateSession:
+ def test_creates_and_refreshes(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ result = repo.create_session(session)
+ mock_db.add.assert_called_once_with(session)
+ mock_db.commit.assert_called_once()
+ mock_db.refresh.assert_called_once_with(session)
+ assert result is session
+
+
+class TestRequireSessionVersion:
+ def test_version_matches(self, repo):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = 3
+ result = repo.require_session_version(session, 3)
+ assert result is session
+
+ def test_version_conflict(self, repo):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = 2
+ with pytest.raises(DatasetReviewSessionVersionConflictError) as exc:
+ repo.require_session_version(session, 5)
+ assert exc.value.expected_version == 5
+ assert exc.value.actual_version == 2
+
+ def test_version_none(self, repo):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = None
+ result = repo.require_session_version(session, 0)
+ assert result is session
+
+
+class TestBumpSessionVersion:
+ def test_bumps_version(self, repo):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = 2
+ result = repo.bump_session_version(session)
+ assert result == 3
+ assert session.version == 3
+
+ def test_bumps_from_none(self, repo):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = None
+ result = repo.bump_session_version(session)
+ assert result == 1
+ assert session.version == 1
+
+
+class TestCommitSessionMutation:
+ def test_commit_success_no_refresh_targets(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = 1
+ result = repo.commit_session_mutation(session)
+ mock_db.commit.assert_called_once()
+ mock_db.refresh.assert_called_once_with(session)
+ assert result is session
+
+ def test_commit_with_refresh_targets(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = 1
+ target = MagicMock()
+ result = repo.commit_session_mutation(session, refresh_targets=[target])
+ assert mock_db.refresh.call_count == 2 # session + target
+ assert result is session
+
+ def test_commit_stale_data_error(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = 2
+ session.session_id = "s1"
+ mock_db.commit.side_effect = StaleDataError("stale")
+ mock_db.query.return_value.filter.return_value.first.return_value = (5,)
+ with pytest.raises(DatasetReviewSessionVersionConflictError) as exc:
+ repo.commit_session_mutation(session, expected_version=2)
+ mock_db.rollback.assert_called_once()
+ assert exc.value.expected_version == 2
+ assert exc.value.actual_version == 5
+
+ def test_commit_stale_no_row(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = 2
+ session.session_id = "s1"
+ mock_db.commit.side_effect = StaleDataError("stale")
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+ with pytest.raises(DatasetReviewSessionVersionConflictError) as exc:
+ repo.commit_session_mutation(session, expected_version=2)
+ assert exc.value.actual_version == 0
+
+ def test_commit_custom_expected_version(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ session.version = 99
+ result = repo.commit_session_mutation(session, expected_version=5)
+ # bump goes from 99 to 100, but expected_version=5 is used for error reporting
+ mock_db.commit.assert_called_once()
+ assert result is session
+
+
+class TestLoadSessionDetail:
+ def test_found(self, repo, mock_db):
+ mock_session = MagicMock(spec=DatasetReviewSession)
+ mock_db.query.return_value.outerjoin.return_value.options.return_value.filter.return_value.filter.return_value.first.return_value = mock_session
+ result = repo.load_session_detail("s1", "u1")
+ assert result is mock_session
+
+ def test_not_found(self, repo, mock_db):
+ mock_db.query.return_value.outerjoin.return_value.options.return_value.filter.return_value.filter.return_value.first.return_value = None
+ result = repo.load_session_detail("s1", "u1")
+ assert result is None
+
+
+class TestSaveDelegates:
+ def test_save_profile_and_findings(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ mock_db.query.return_value.filter.return_value.first.return_value = session
+ profile = MagicMock(spec=DatasetProfile)
+ findings = [MagicMock(spec=ValidationFinding)]
+ with patch("src.services.dataset_review.repositories.repository_pkg._mutations.save_profile_and_findings") as mock_save:
+ mock_save.return_value = session
+ result = repo.save_profile_and_findings("s1", "u1", profile, findings)
+ mock_save.assert_called_once()
+ assert result is session
+
+ def test_save_recovery_state(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ mock_db.query.return_value.filter.return_value.first.return_value = session
+ imported_filters = [MagicMock(spec=ImportedFilter)]
+ template_variables = [MagicMock(spec=TemplateVariable)]
+ execution_mappings = [MagicMock(spec=ExecutionMapping)]
+ with patch("src.services.dataset_review.repositories.repository_pkg._mutations.save_recovery_state") as mock_save:
+ mock_save.return_value = session
+ result = repo.save_recovery_state("s1", "u1", imported_filters, template_variables, execution_mappings)
+ mock_save.assert_called_once()
+ assert result is session
+
+ def test_save_preview(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ mock_db.query.return_value.filter.return_value.first.return_value = session
+ preview = MagicMock(spec=CompiledPreview)
+ with patch("src.services.dataset_review.repositories.repository_pkg._mutations.save_preview") as mock_save:
+ mock_save.return_value = preview
+ result = repo.save_preview("s1", "u1", preview)
+ mock_save.assert_called_once()
+ assert result is preview
+
+ def test_save_run_context(self, repo, mock_db):
+ session = MagicMock(spec=DatasetReviewSession)
+ mock_db.query.return_value.filter.return_value.first.return_value = session
+ run_context = MagicMock(spec=DatasetRunContext)
+ with patch("src.services.dataset_review.repositories.repository_pkg._mutations.save_run_context") as mock_save:
+ mock_save.return_value = run_context
+ result = repo.save_run_context("s1", "u1", run_context)
+ mock_save.assert_called_once()
+ assert result is run_context
+
+
+class TestListSessionsForUser:
+ def test_returns_list(self, repo, mock_db):
+ mock_sessions = [MagicMock(spec=DatasetReviewSession), MagicMock(spec=DatasetReviewSession)]
+ mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = mock_sessions
+ result = repo.list_sessions_for_user("u1")
+ assert len(result) == 2
+ assert result == mock_sessions
+
+ def test_empty(self, repo, mock_db):
+ mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
+ result = repo.list_sessions_for_user("u1")
+ assert result == []
+
+
+class TestVersionConflictError:
+ def test_str(self):
+ exc = DatasetReviewSessionVersionConflictError("s1", 1, 5)
+ assert str(exc) == "Session version conflict: expected 1, actual 5"
+ assert exc.session_id == "s1"
+ assert exc.expected_version == 1
+ assert exc.actual_version == 5
+# #endregion Test.DatasetReview.SessionRepository
diff --git a/backend/tests/services/git/test_git_base_edge.py b/backend/tests/services/git/test_git_base_edge.py
new file mode 100644
index 00000000..36c6b27c
--- /dev/null
+++ b/backend/tests/services/git/test_git_base_edge.py
@@ -0,0 +1,264 @@
+# #region Test.Git.Base.Edge [C:3] [TYPE Module] [SEMANTICS test, git, base, repo, migrate, coverage]
+# @BRIEF Additional edge case tests for GitServiceBase — repo path resolution, migration, delete_repo edge paths.
+# @RELATION BINDS_TO -> [GitServiceBase]
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
+
+import os
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import HTTPException
+
+from src.services.git._base import GitServiceBase
+
+
+class TestResolveBasePath:
+ def test_custom_base_path_returns_direct(self):
+ """Custom base_path (not 'git_repos') returns path directly."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
+ patch.object(GitServiceBase, '_resolve_base_path', return_value="/custom/path"):
+ svc = GitServiceBase(base_path="/custom/path")
+ # Already resolved in __init__
+ assert svc.base_path == "/custom/path"
+
+ def test_fallback_on_db_error(self):
+ """DB error during resolve → fallback to default."""
+ with patch("src.services.git._base.SessionLocal", side_effect=Exception("DB error")):
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'):
+ svc = GitServiceBase(base_path="git_repos")
+ assert svc.base_path is not None
+
+
+class TestGetRepoPathDBPath:
+ @pytest.mark.asyncio
+ async def test_db_path_exists_returned(self):
+ """DB has local_path and it exists → return it."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'):
+ svc = GitServiceBase(base_path="git_repos")
+ svc.base_path = "/tmp/base"
+ svc.legacy_base_path = "/tmp/legacy"
+ svc._uses_default_base_path = True
+
+ with patch("src.services.git._base.SessionLocal") as mock_sl:
+ mock_db = MagicMock()
+ mock_sl.return_value = mock_db
+ mock_db_repo = MagicMock()
+ mock_db_repo.local_path = "/tmp/existing/repo"
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
+ with patch("os.path.exists", return_value=True), \
+ patch("os.path.abspath", side_effect=lambda x: x):
+ result = await svc._get_repo_path(1, "my-key")
+ assert result == "/tmp/existing/repo"
+
+ @pytest.mark.asyncio
+ async def test_db_path_legacy_migration(self):
+ """DB path starts with legacy base → migrate."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'):
+ svc = GitServiceBase(base_path="git_repos")
+ svc.base_path = "/tmp/new-base"
+ svc.legacy_base_path = "/tmp/legacy"
+ svc._uses_default_base_path = True
+
+ with patch("src.services.git._base.SessionLocal") as mock_sl:
+ mock_db = MagicMock()
+ mock_sl.return_value = mock_db
+ mock_db_repo = MagicMock()
+ mock_db_repo.local_path = "/tmp/legacy/repo"
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
+ with patch("os.path.exists", return_value=True), \
+ patch("os.path.abspath", side_effect=lambda x: x), \
+ patch.object(svc, '_migrate_repo_directory', new_callable=AsyncMock, return_value="/tmp/new-base/my-key"):
+ result = await svc._get_repo_path(1, "my-key")
+ assert result == "/tmp/new-base/my-key"
+
+ @pytest.mark.asyncio
+ async def test_db_query_exception(self):
+ """Exception in DB query → fallback to path resolution."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'):
+ svc = GitServiceBase(base_path="git_repos")
+ svc.base_path = "/tmp/base"
+ svc.legacy_base_path = "/tmp/legacy"
+ svc._uses_default_base_path = True
+
+ with patch("src.services.git._base.SessionLocal", side_effect=Exception("DB down")):
+ result = await svc._get_repo_path(1, "my-key")
+ assert result == "/tmp/base/my-key"
+
+ @pytest.mark.asyncio
+ async def test_legacy_id_path_migration(self):
+ """Legacy ID path exists → migrate to new path."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'):
+ svc = GitServiceBase(base_path="git_repos")
+ svc.base_path = "/tmp/base"
+ svc.legacy_base_path = "/tmp/legacy"
+ svc._uses_default_base_path = True
+
+ with patch("src.services.git._base.SessionLocal") as mock_sl:
+ mock_db = MagicMock()
+ mock_sl.return_value = mock_db
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+ with patch("os.path.exists", side_effect=lambda p: p == "/tmp/legacy/1"), \
+ patch.object(svc, '_migrate_repo_directory', new_callable=AsyncMock, return_value="/tmp/base/dashboard"):
+ result = await svc._get_repo_path(1)
+ assert result == "/tmp/base/dashboard"
+
+ @pytest.mark.asyncio
+ async def test_target_path_exists_updates_db(self):
+ """Target path already exists → update DB record."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'):
+ svc = GitServiceBase(base_path="git_repos")
+ svc.base_path = "/tmp/base"
+ svc.legacy_base_path = "/tmp/legacy"
+ svc._uses_default_base_path = True
+
+ with patch("src.services.git._base.SessionLocal") as mock_sl:
+ mock_db = MagicMock()
+ mock_sl.return_value = mock_db
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+ with patch("os.path.exists", return_value=True), \
+ patch.object(svc, '_update_repo_local_path') as mock_update:
+ result = await svc._get_repo_path(1, "my-key")
+ assert result == "/tmp/base/my-key"
+ mock_update.assert_called_once()
+
+
+class TestDeleteRepoEdge:
+ @pytest.mark.asyncio
+ async def test_delete_repo_file_not_dir(self):
+ """Repo path exists but is a file → use os.remove."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'):
+
+ svc = GitServiceBase(base_path="git_repos")
+ svc.base_path = "/tmp/base"
+ with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo-file"), \
+ 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))()):
+
+ import src.services.git._base as gb_mod
+ orig_sl = gb_mod.SessionLocal
+ try:
+ 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)
+ mock_remove.assert_called_once_with("/tmp/repo-file")
+ finally:
+ gb_mod.SessionLocal = orig_sl
+
+ @pytest.mark.asyncio
+ async def test_delete_repo_db_error_rollback(self):
+ """DB error during delete → rollback and HTTP 500."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'):
+
+ svc = GitServiceBase(base_path="git_repos")
+ svc.base_path = "/tmp/base"
+ 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))()):
+
+ import src.services.git._base as gb_mod
+ orig_sl = gb_mod.SessionLocal
+ try:
+ mock_db = MagicMock()
+ gb_mod.SessionLocal = MagicMock(return_value=mock_db)
+ mock_db_repo = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
+ mock_db.delete.side_effect = Exception("DB error")
+ gb_mod.run_blocking = AsyncMock()
+
+ with pytest.raises(HTTPException, match="500"):
+ await svc.delete_repo(1)
+ mock_db.rollback.assert_called_once()
+ finally:
+ gb_mod.SessionLocal = orig_sl
+
+
+class TestMigrateRepoDirectoryEdges:
+ @pytest.mark.asyncio
+ async def test_os_replace_fallback_to_shutil(self):
+ """OSError on os.replace → falls back to shutil.move."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
+ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
+ svc = GitServiceBase(base_path="/tmp/test")
+ with patch("os.path.exists", return_value=False), \
+ patch.object(svc, '_update_repo_local_path'), \
+ patch("src.services.git._base.run_blocking", new_callable=AsyncMock) as mock_blocking:
+ # Make first os.replace fail, second shutil.move succeed
+ mock_blocking.side_effect = [None, OSError("rename failed"), None]
+ result = await svc._migrate_repo_directory(1, "/source", "/target")
+ assert result == "/target"
+
+ @pytest.mark.asyncio
+ async def test_migrate_existing_target_keeps_source(self):
+ """Target already exists → return source."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
+ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
+ svc = GitServiceBase(base_path="/tmp/test")
+ with patch("os.path.exists", return_value=True):
+ result = await svc._migrate_repo_directory(1, "/source", "/target")
+ assert result == "/source"
+
+
+class TestInitRepoEdge:
+ @pytest.mark.asyncio
+ async def test_existing_path_is_not_git_repo_recreates(self):
+ """Path exists but is not a valid Git repo → recreate."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
+ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
+ svc = GitServiceBase(base_path="/tmp/test")
+ with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
+ patch("os.path.exists", return_value=True), \
+ 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))()):
+
+ # First call (Repo(repo_path)) raises InvalidGitRepositoryError
+ mock_blocking.side_effect = [Exception("InvalidGitRepositoryError"), None, None]
+ mock_clone.return_value = MagicMock()
+ result = await svc.init_repo(1, "https://remote.com/repo.git", "pat")
+ assert result is not None
+
+ @pytest.mark.asyncio
+ async def test_stale_path_unlink_fallback(self):
+ """stale_path.exists after rmtree → tries unlink."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
+ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
+ svc = GitServiceBase(base_path="/tmp/test")
+ with patch.object(svc, "_get_repo_path", new_callable=AsyncMock, return_value="/tmp/repo"), \
+ patch("os.path.exists", return_value=True), \
+ 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("pathlib.Path.exists", return_value=True):
+
+ mock_blocking.side_effect = [
+ Exception("InvalidGitRepositoryError"), # Repo() call
+ None, # shutil.rmtree
+ None, # stale_path.unlink
+ MagicMock(), # Repo from clone
+ ]
+ mock_clone.return_value = MagicMock()
+ result = await svc.init_repo(1, "https://remote.com/repo.git", "pat")
+ assert result is not None
+
+
+class TestClosedService:
+ def test_closed_service_blocks_locked(self):
+ """Closed service raises RuntimeError in _locked."""
+ with patch.object(GitServiceBase, '_ensure_base_path_exists'), \
+ patch.object(GitServiceBase, '_resolve_base_path', return_value="/tmp/test"):
+ svc = GitServiceBase(base_path="/tmp/test")
+ svc._closed = True
+ with pytest.raises(RuntimeError, match="closed"):
+ with svc._locked(1):
+ pass
+# #endregion Test.Git.Base.Edge
diff --git a/backend/tests/services/git/test_git_merge_edge.py b/backend/tests/services/git/test_git_merge_edge.py
new file mode 100644
index 00000000..0436cb8a
--- /dev/null
+++ b/backend/tests/services/git/test_git_merge_edge.py
@@ -0,0 +1,264 @@
+# #region Test.Git.Merge.Edge [C:4] [TYPE Module] [SEMANTICS test, git, merge, conflict, coverage]
+# @BRIEF Edge case tests for GitServiceMergeMixin — merge operations, conflict resolution, abort, continue, promote.
+# @RELATION BINDS_TO -> [GitServiceMergeMixin]
+
+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 pytest
+from fastapi import HTTPException
+from git.exc import GitCommandError
+from git import Repo
+from git.objects.blob import Blob
+
+
+@pytest.fixture
+def mixin():
+ from src.services.git._merge import GitServiceMergeMixin
+ m = GitServiceMergeMixin()
+ m._locked = lambda x: (lambda g=None: (yield))() # no-op context manager
+ return m
+
+
+class TestReadBlobText:
+ def test_none_blob(self, mixin):
+ assert mixin._read_blob_text(None) == ""
+
+ def test_decode_error(self, mixin):
+ blob = MagicMock(spec=Blob)
+ blob.data_stream.read.return_value = b"\xff\xfe"
+ result = mixin._read_blob_text(blob)
+ assert result == "" or result is not None
+
+ def test_success(self, mixin):
+ blob = MagicMock(spec=Blob)
+ blob.data_stream.read.return_value = b"hello world"
+ result = mixin._read_blob_text(blob)
+ assert result == "hello world"
+
+
+class TestGetUnmergedFilePaths:
+ def test_success(self, mixin):
+ repo = MagicMock(spec=Repo)
+ repo.index.unmerged_blobs.return_value = {"file1.txt": [], "file2.txt": []}
+ result = mixin._get_unmerged_file_paths(repo)
+ assert result == ["file1.txt", "file2.txt"]
+
+ def test_exception(self, mixin):
+ repo = MagicMock(spec=Repo)
+ repo.index.unmerged_blobs.side_effect = Exception("error")
+ result = mixin._get_unmerged_file_paths(repo)
+ assert result == []
+
+
+class TestGetMergeStatus:
+ @pytest.mark.asyncio
+ async def test_no_unfinished_merge(self, mixin):
+ repo = MagicMock(spec=Repo)
+ 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), \
+ patch("os.path.exists", return_value=False):
+ result = await mixin.get_merge_status(1)
+ assert result["has_unfinished_merge"] is False
+ assert result["current_branch"] == "main"
+
+ @pytest.mark.asyncio
+ async def test_unfinished_merge(self, mixin):
+ repo = MagicMock(spec=Repo)
+ repo.git_dir = "/tmp/repo/.git"
+ repo.working_tree_dir = "/tmp/repo"
+
+ with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
+ patch("os.path.exists", return_value=True), \
+ 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,
+ }):
+ result = await mixin.get_merge_status(1)
+ assert result["has_unfinished_merge"] is True
+ assert result["conflicts_count"] == 2
+
+ @pytest.mark.asyncio
+ async def test_active_branch_exception(self, mixin):
+ repo = MagicMock(spec=Repo)
+ repo.git_dir = "/tmp/repo/.git"
+ repo.working_tree_dir = "/tmp/repo"
+ 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), \
+ patch("os.path.exists", return_value=False):
+ result = await mixin.get_merge_status(1)
+ assert result["current_branch"] == "detached_or_unknown"
+
+
+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):
+ 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 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 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):
+ 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")
+
+ 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):
+ 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")
+
+ 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), \
+ patch("os.path.abspath", return_value="/tmp/repo/f.txt"), \
+ patch("os.makedirs"), \
+ patch("builtins.open", MagicMock()):
+ result = mixin.resolve_merge_conflicts(1, [
+ {"file_path": "f.txt", "resolution": "manual", "content": "new content"}
+ ])
+ assert result == ["f.txt"]
+
+ 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 pytest.raises(HTTPException, match="Invalid conflict file path"):
+ mixin.resolve_merge_conflicts(1, [
+ {"file_path": "../../etc/passwd", "resolution": "manual", "content": "x"}
+ ])
+
+ 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"}])
+
+
+class TestAbortMerge:
+ def test_success(self, mixin):
+ repo = MagicMock(spec=Repo)
+ with patch.object(mixin, 'get_repo', return_value=repo):
+ result = mixin.abort_merge(1)
+ assert result["status"] == "aborted"
+ repo.git.merge.assert_called_with("--abort")
+
+ 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):
+ 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 pytest.raises(HTTPException, match="Cannot abort"):
+ mixin.abort_merge(1)
+
+
+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 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), \
+ patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
+ result = mixin.continue_merge(1, message="Resolved conflicts")
+ assert result["status"] == "committed"
+ repo.git.commit.assert_called_with("-m", "Resolved conflicts")
+
+ 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), \
+ patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
+ result = mixin.continue_merge(1)
+ assert result["status"] == "committed"
+ repo.git.commit.assert_called_with("--no-edit")
+
+ def test_nothing_to_commit(self, mixin):
+ 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), \
+ patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
+ result = mixin.continue_merge(1)
+ assert result["status"] == "already_clean"
+
+ def test_commit_error(self, mixin):
+ 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), \
+ patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
+ with pytest.raises(HTTPException, match="Cannot continue"):
+ mixin.continue_merge(1)
+
+ def test_commit_hash_exception(self, mixin):
+ """Exception getting commit hash → empty string."""
+ repo = MagicMock(spec=Repo)
+ repo.index.unmerged_blobs.return_value = {}
+ repo.head.commit.hexsha = "abc123"
+ def raiser():
+ 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), \
+ patch.object(mixin, '_get_unmerged_file_paths', return_value=[]):
+ result = mixin.continue_merge(1, message="msg")
+ assert result["status"] == "committed"
+ assert result["commit_hash"] == ""
+
+
+class TestPromoteDirectMerge:
+ def test_raises_on_missing_branches(self, mixin):
+ with pytest.raises(HTTPException, match="required"):
+ 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")
+# #endregion Test.Git.Merge.Edge
diff --git a/backend/tests/services/git/test_git_sync_edge.py b/backend/tests/services/git/test_git_sync_edge.py
new file mode 100644
index 00000000..e8e0603e
--- /dev/null
+++ b/backend/tests/services/git/test_git_sync_edge.py
@@ -0,0 +1,241 @@
+# #region Test.Git.Sync.Edge [C:3] [TYPE Module] [SEMANTICS test, git, sync, push, pull, coverage]
+# @BRIEF Edge case tests for GitServiceSyncMixin — push/pull paths.
+# @RELATION BINDS_TO -> [GitServiceSyncMixin]
+
+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 pytest
+from fastapi import HTTPException
+from git.exc import GitCommandError
+
+
+class TestPushChanges:
+ @pytest.mark.asyncio
+ async def test_no_branches(self):
+ from src.services.git._sync import GitServiceSyncMixin
+ mixin = GitServiceSyncMixin()
+ mixin._locked = lambda x: (lambda g=None: (yield))()
+ repo = MagicMock()
+ repo.heads = []
+ with patch.object(mixin, 'get_repo', new_callable=MagicMock, 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))()
+ 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 pytest.raises(HTTPException, match="origin"):
+ await mixin.push_changes(1)
+
+ @pytest.mark.asyncio
+ 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._align_origin_host_with_config = MagicMock(return_value=None)
+ repo = MagicMock()
+ repo.heads = [MagicMock()]
+ origin = MagicMock()
+ repo.remote.return_value = origin
+ current_branch = MagicMock()
+ current_branch.name = "feature"
+ 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("src.services.git._sync.SessionLocal") as mock_sl_cls:
+ mock_db = MagicMock()
+ mock_sl_cls.return_value = mock_db
+ mock_db_repo = MagicMock()
+ mock_db_repo.remote_url = "https://git.com/org/repo.git"
+ mock_db_repo.config_id = 1
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
+ mock_db_config = MagicMock()
+ mock_db_config.url = "https://git.com"
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_db_config
+
+ await mixin.push_changes(1)
+ repo.git.push.assert_called_with("--set-upstream", "origin", "feature:feature")
+
+ @pytest.mark.asyncio
+ 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._align_origin_host_with_config = MagicMock(return_value=None)
+ repo = MagicMock()
+ repo.heads = [MagicMock()]
+ origin = MagicMock()
+ repo.remote.return_value = origin
+ current_branch = MagicMock()
+ current_branch.name = "main"
+ 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("src.services.git._sync.SessionLocal") as mock_sl_cls:
+ mock_db = MagicMock()
+ mock_sl_cls.return_value = mock_db
+ mock_db.query.return_value.filter.return_value.first.return_value = None # no db_repo
+
+ await mixin.push_changes(1)
+ origin.push.assert_called_once()
+
+ @pytest.mark.asyncio
+ 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._align_origin_host_with_config = MagicMock(return_value=None)
+ repo = MagicMock()
+ repo.heads = [MagicMock()]
+ origin = MagicMock()
+ repo.remote.return_value = origin
+ current_branch = MagicMock()
+ current_branch.name = "main"
+ current_branch.tracking_branch.return_value = None
+ 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("src.services.git._sync.SessionLocal") as mock_sl_cls:
+ mock_db = MagicMock()
+ mock_sl_cls.return_value = mock_db
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+ with pytest.raises(HTTPException, match="conflict|rejected|409"):
+ await mixin.push_changes(1)
+
+ @pytest.mark.asyncio
+ 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._align_origin_host_with_config = MagicMock(return_value=None)
+ repo = MagicMock()
+ repo.heads = [MagicMock()]
+ origin = MagicMock()
+ repo.remote.return_value = origin
+ current_branch = MagicMock()
+ current_branch.name = "main"
+ current_branch.tracking_branch.return_value = None
+ 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("src.services.git._sync.SessionLocal") as mock_sl_cls:
+ mock_db = MagicMock()
+ mock_sl_cls.return_value = mock_db
+ mock_db.query.return_value.filter.return_value.first.return_value = None
+ with pytest.raises(HTTPException, match="500"):
+ await mixin.push_changes(1)
+
+
+class TestPullChanges:
+ @pytest.mark.asyncio
+ async def test_unfinished_merge_detected(self):
+ from src.services.git._sync import GitServiceSyncMixin
+ mixin = GitServiceSyncMixin()
+ mixin._locked = lambda x: (lambda g=None: (yield))()
+ 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), \
+ patch("os.path.exists", return_value=True), \
+ patch.object(mixin, '_build_unfinished_merge_payload', return_value={"error_code": "GIT_UNFINISHED_MERGE"}):
+ with pytest.raises(HTTPException, match="409"):
+ await mixin.pull_changes(1)
+
+ @pytest.mark.asyncio
+ 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))()
+ repo = MagicMock()
+ repo.git_dir = "/tmp/repo/.git"
+ repo.working_tree_dir = "/tmp/repo"
+ current_branch = MagicMock()
+ current_branch.name = "feature"
+ repo.active_branch = current_branch
+ origin = MagicMock()
+ repo.remote.return_value = origin
+ repo.refs = []
+
+ with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
+ patch("os.path.exists", return_value=False):
+ with pytest.raises(HTTPException, match="409"):
+ await mixin.pull_changes(1)
+
+ @pytest.mark.asyncio
+ async def test_conflict_during_pull(self):
+ from src.services.git._sync import GitServiceSyncMixin
+ mixin = GitServiceSyncMixin()
+ mixin._locked = lambda x: (lambda g=None: (yield))()
+ repo = MagicMock()
+ repo.git_dir = "/tmp/repo/.git"
+ repo.working_tree_dir = "/tmp/repo"
+ current_branch = MagicMock()
+ current_branch.name = "main"
+ repo.active_branch = current_branch
+ origin = MagicMock()
+ repo.remote.return_value = origin
+ # Simulate remote branch existence
+ ref = MagicMock()
+ ref.name = "origin/main"
+ repo.refs = [ref]
+ repo.git.pull.side_effect = GitCommandError("pull", "conflict")
+
+ with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
+ patch("os.path.exists", return_value=False):
+ with pytest.raises(HTTPException, match="409"):
+ await mixin.pull_changes(1)
+
+ @pytest.mark.asyncio
+ async def test_origin_not_found(self):
+ from src.services.git._sync import GitServiceSyncMixin
+ mixin = GitServiceSyncMixin()
+ mixin._locked = lambda x: (lambda g=None: (yield))()
+ 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), \
+ patch("os.path.exists", return_value=False):
+ with pytest.raises(HTTPException, match="origin"):
+ await mixin.pull_changes(1)
+
+ @pytest.mark.asyncio
+ async def test_generic_pull_error(self):
+ from src.services.git._sync import GitServiceSyncMixin
+ mixin = GitServiceSyncMixin()
+ mixin._locked = lambda x: (lambda g=None: (yield))()
+ repo = MagicMock()
+ repo.git_dir = "/tmp/repo/.git"
+ repo.working_tree_dir = "/tmp/repo"
+ current_branch = MagicMock()
+ current_branch.name = "main"
+ repo.active_branch = current_branch
+ origin = MagicMock()
+ repo.remote.return_value = origin
+ ref = MagicMock()
+ ref.name = "origin/main"
+ repo.refs = [ref]
+ repo.git.pull.side_effect = Exception("unknown error")
+
+ with patch.object(mixin, 'get_repo', new_callable=MagicMock, return_value=repo), \
+ patch("os.path.exists", return_value=False):
+ with pytest.raises(HTTPException, match="500"):
+ await mixin.pull_changes(1)
+# #endregion Test.Git.Sync.Edge
diff --git a/backend/tests/services/git/test_git_url_edge.py b/backend/tests/services/git/test_git_url_edge.py
new file mode 100644
index 00000000..affa6403
--- /dev/null
+++ b/backend/tests/services/git/test_git_url_edge.py
@@ -0,0 +1,256 @@
+# #region Test.Git.Url.Edge [C:3] [TYPE Module] [SEMANTICS test, git, url, parse, coverage]
+# @BRIEF Edge case tests for GitServiceUrlMixin — URL parsing, host extraction, alignment, identity parsing.
+# @RELATION BINDS_TO -> [GitServiceUrlMixin]
+
+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 pytest
+
+from src.services.git._url import GitServiceUrlMixin
+
+
+@pytest.fixture
+def mixin():
+ return GitServiceUrlMixin()
+
+
+class TestExtractHttpHost:
+ def test_none_or_empty(self, mixin):
+ assert mixin._extract_http_host(None) is None
+ assert mixin._extract_http_host("") is None
+ assert mixin._extract_http_host(" ") is None
+
+ def test_non_http_scheme(self, mixin):
+ assert mixin._extract_http_host("ssh://git@example.com") is None
+ assert mixin._extract_http_host("git@example.com:repo.git") is None
+
+ def test_http_with_port(self, mixin):
+ result = mixin._extract_http_host("https://example.com:8443/repo")
+ assert result == "example.com:8443"
+
+ def test_http_without_port(self, mixin):
+ result = mixin._extract_http_host("https://example.com/repo")
+ assert result == "example.com"
+
+ def test_parse_error(self, mixin):
+ # urlparse doesn't typically raise, but test the except path
+ result = mixin._extract_http_host(None)
+ assert result is None
+
+ def test_no_hostname(self, mixin):
+ with patch("src.services.git._url.urlparse") as mock_parse:
+ parsed = MagicMock()
+ parsed.scheme = "http"
+ parsed.hostname = None
+ mock_parse.return_value = parsed
+ result = mixin._extract_http_host("http:///path")
+ assert result is None
+
+
+class TestStripUrlCredentials:
+ def test_empty(self, mixin):
+ assert mixin._strip_url_credentials("") == ""
+
+ def test_no_credentials(self, mixin):
+ url = "https://example.com/repo.git"
+ assert mixin._strip_url_credentials(url) == url
+
+ def test_with_credentials(self, mixin):
+ url = "https://user:pass@example.com/repo.git"
+ result = mixin._strip_url_credentials(url)
+ assert "user" not in result
+ assert "pass" not in result
+ assert "example.com/repo.git" in result
+
+ def test_non_http_scheme(self, mixin):
+ url = "ssh://git@example.com/repo.git"
+ assert mixin._strip_url_credentials(url) == url
+
+ def test_parse_error(self, mixin):
+ with patch("src.services.git._url.urlparse", side_effect=Exception("parse error")):
+ url = "https://example.com/repo"
+ result = mixin._strip_url_credentials(url)
+ assert result == url
+
+
+class TestReplaceHostInUrl:
+ def test_empty_source(self, mixin):
+ assert mixin._replace_host_in_url("", "https://new.com") is None
+
+ def test_empty_config(self, mixin):
+ assert mixin._replace_host_in_url("https://old.com/repo", "") is None
+
+ def test_non_http_scheme(self, mixin):
+ assert mixin._replace_host_in_url("ssh://old.com", "https://new.com") is None
+
+ def test_no_hostname(self, mixin):
+ result = mixin._replace_host_in_url("http:///path", "https://new.com")
+ assert result is None
+
+ def test_successful_replacement(self, mixin):
+ result = mixin._replace_host_in_url(
+ "https://old.com:8080/org/repo.git",
+ "https://new.com:8443"
+ )
+ assert result is not None
+ assert "new.com:8443" in result
+ assert "old.com" not in result
+
+ def test_preserves_credentials(self, mixin):
+ result = mixin._replace_host_in_url(
+ "https://user:pass@old.com/org/repo.git",
+ "https://new.com"
+ )
+ assert result is not None
+ assert "user" in result
+ assert "new.com" in result
+
+ def test_parse_error(self, mixin):
+ with patch("src.services.git._url.urlparse", side_effect=Exception("parse error")):
+ result = mixin._replace_host_in_url("https://old.com", "https://new.com")
+ assert result is None
+
+
+class TestParseRemoteRepoIdentity:
+ def test_empty_url(self, mixin):
+ with pytest.raises(Exception, match="empty"):
+ mixin._parse_remote_repo_identity("")
+
+ def test_ssh_url(self, mixin):
+ result = mixin._parse_remote_repo_identity("git@github.com:owner/repo.git")
+ assert result["owner"] == "owner"
+ assert result["repo"] == "repo"
+ assert result["full_name"] == "owner/repo"
+
+ def test_https_url(self, mixin):
+ result = mixin._parse_remote_repo_identity("https://github.com/owner/repo.git")
+ assert result["owner"] == "owner"
+ assert result["repo"] == "repo"
+
+ def test_nested_path(self, mixin):
+ result = mixin._parse_remote_repo_identity("https://github.com/org/team/repo.git")
+ assert result["owner"] == "org"
+ assert result["repo"] == "repo"
+ assert result["namespace"] == "org/team"
+
+ def test_no_git_suffix(self, mixin):
+ result = mixin._parse_remote_repo_identity("https://github.com/owner/repo")
+ assert result["repo"] == "repo"
+ assert result["full_name"] == "owner/repo"
+
+ def test_too_few_parts(self, mixin):
+ with pytest.raises(Exception, match="Cannot parse"):
+ 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"
+
+
+class TestDeriveServerUrlFromRemote:
+ def test_empty(self, mixin):
+ assert mixin._derive_server_url_from_remote("") is None
+
+ def test_ssh_url_returns_none(self, mixin):
+ assert mixin._derive_server_url_from_remote("git@github.com:org/repo.git") is None
+
+ def test_non_http_scheme(self, mixin):
+ assert mixin._derive_server_url_from_remote("ftp://example.com/repo") is None
+
+ def test_no_hostname(self, mixin):
+ assert mixin._derive_server_url_from_remote("http:///path") is None
+
+ def test_success_https(self, mixin):
+ result = mixin._derive_server_url_from_remote("https://github.com/org/repo.git")
+ assert result == "https://github.com"
+
+ def test_with_port(self, mixin):
+ result = mixin._derive_server_url_from_remote("https://gitea.local:3000/org/repo.git")
+ assert result == "https://gitea.local:3000"
+
+
+class TestNormalizeGitServerUrl:
+ def test_empty_raises(self, mixin):
+ with pytest.raises(Exception, match="required"):
+ mixin._normalize_git_server_url("")
+
+ def test_strips_trailing_slash(self, mixin):
+ result = mixin._normalize_git_server_url("https://github.com/")
+ assert result == "https://github.com"
+
+ def test_preserves_no_slash(self, mixin):
+ result = mixin._normalize_git_server_url("https://github.com")
+ assert result == "https://github.com"
+
+
+class TestAlignOriginHostWithConfig:
+ def test_no_config_host(self, mixin):
+ result = mixin._align_origin_host_with_config(1, MagicMock(), None, None, None)
+ assert result is None
+
+ def test_no_origin_host(self, mixin):
+ result = mixin._align_origin_host_with_config(
+ 1, MagicMock(), "https://new.com", "", None
+ )
+ assert result is None
+
+ def test_hosts_match(self, mixin):
+ result = mixin._align_origin_host_with_config(
+ 1, MagicMock(), "https://same.com", "https://same.com/repo", None
+ )
+ assert result is None
+
+ def test_hosts_mismatch_sets_url(self, mixin):
+ origin = MagicMock()
+ result = mixin._align_origin_host_with_config(
+ 1, origin, "https://new.com", "https://old.com/repo", None
+ )
+ assert result is not None
+ assert "new.com" in result
+ origin.set_url.assert_called_once()
+
+ def test_set_url_failure(self, mixin):
+ origin = MagicMock()
+ origin.set_url.side_effect = Exception("set_url failed")
+ result = mixin._align_origin_host_with_config(
+ 1, origin, "https://new.com", "https://old.com/repo", None
+ )
+ assert result is None
+
+ def test_sets_aligned_url(self, mixin):
+ origin = MagicMock()
+ with patch("src.services.git._url.SessionLocal") as mock_sl:
+ mock_db = MagicMock()
+ mock_sl.return_value = mock_db
+ mock_db_repo = MagicMock()
+ mock_db.query.return_value.filter.return_value.first.return_value = mock_db_repo
+ result = mixin._align_origin_host_with_config(
+ 1, origin, "https://new.com", "https://old.com/repo", None
+ )
+ assert result is not None
+ mock_db_repo.remote_url = mixin._strip_url_credentials(result)
+ assert mock_db_repo.commit.called
+
+ def test_db_update_failure(self, mixin):
+ origin = MagicMock()
+ with patch("src.services.git._url.SessionLocal", side_effect=Exception("DB error")):
+ result = mixin._align_origin_host_with_config(
+ 1, origin, "https://new.com", "https://old.com/repo", None
+ )
+ assert result is not None # still returns aligned URL
+
+ def test_uses_binding_remote_url_when_current_empty(self, mixin):
+ origin = MagicMock()
+ result = mixin._align_origin_host_with_config(
+ 1, origin, "https://new.com", "", "https://old.com/repo"
+ )
+ assert result is not None
+ assert "old.com" in result
+# #endregion Test.Git.Url.Edge
diff --git a/backend/tests/services/maintenance/test_banner_renderer.py b/backend/tests/services/maintenance/test_banner_renderer.py
new file mode 100644
index 00000000..2ab52bca
--- /dev/null
+++ b/backend/tests/services/maintenance/test_banner_renderer.py
@@ -0,0 +1,270 @@
+# #region Test.Maintenance.BannerRenderer [C:3] [TYPE Module] [SEMANTICS test, maintenance, banner, renderer, coverage]
+# @BRIEF Tests for banner renderer — build_banner_text, _build_banner_text_for_dashboard, rebuild_banner.
+# @RELATION BINDS_TO -> [MaintenanceBannerRenderer]
+
+import sys
+from pathlib import Path
+sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
+
+from datetime import UTC, datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from src.models.maintenance import (
+ MaintenanceDashboardBanner,
+ MaintenanceDashboardBannerStatus,
+ MaintenanceDashboardState,
+ MaintenanceDashboardStateStatus,
+ MaintenanceEvent,
+ MaintenanceEventStatus,
+ MaintenanceSettings,
+)
+
+
+class TestBuildBannerText:
+ @pytest.fixture
+ def single_event(self):
+ return [{"start_time": "2026-06-15T10:00:00Z", "end_time": "2026-06-15T12:00:00Z", "message": "Scheduled maintenance"}]
+
+ def test_empty_events(self):
+ from src.services.maintenance._banner_renderer import build_banner_text
+ assert build_banner_text([], "Template") == ""
+
+ def test_single_event_substitution(self, single_event):
+ from src.services.maintenance._banner_renderer import build_banner_text
+ result = build_banner_text(single_event, "Maintenance: {message} ({start_time} - {end_time})")
+ assert "Scheduled maintenance" in result
+ assert "2026-06-15 10:00" in result
+ assert "2026-06-15 12:00" in result
+
+ def test_single_event_with_unknown_variables_removed(self):
+ from src.services.maintenance._banner_renderer import build_banner_text
+ events = [{"start_time": None, "end_time": None, "message": "Test"}]
+ result = build_banner_text(events, "Msg: {message} ({unknown_var})")
+ assert "Msg: Test" in result
+ assert "{unknown_var}" not in result
+
+ def test_multi_event_combined(self):
+ from src.services.maintenance._banner_renderer import build_banner_text
+ events = [
+ {"start_time": "2026-06-15T10:00:00Z", "end_time": "2026-06-15T12:00:00Z", "message": "First event"},
+ {"start_time": "2026-06-16T10:00:00Z", "end_time": "2026-06-16T12:00:00Z", "message": "Second event"},
+ ]
+ result = build_banner_text(events, "Event: {message}")
+ assert "First event" in result
+ assert "Second event" in result
+ assert "---" in result # separator
+
+ def test_multi_event_with_single_event_path(self):
+ """Multiple events path but len(events)==1 comparison after join."""
+ from src.services.maintenance._banner_renderer import build_banner_text
+ events = [{"start_time": "2026-06-15T10:00:00Z", "end_time": None, "message": "Test"}]
+ result = build_banner_text(events, "Msg: {message} ({start_time})")
+ assert "Test" in result
+ assert "2026-06-15 10:00" in result
+
+ def test_html_escaping(self):
+ from src.services.maintenance._banner_renderer import build_banner_text
+ events = [{"start_time": None, "end_time": None, "message": ""}]
+ result = build_banner_text(events, "{message}")
+ assert "<script>" in result
+ assert "