From 4e6bfa8549a7bed46c48d5193de5336659672619 Mon Sep 17 00:00:00 2001 From: busya Date: Mon, 15 Jun 2026 17:31:43 +0300 Subject: [PATCH] =?UTF-8?q?test:=208=20parallel=20agents=20=E2=80=94=20fix?= =?UTF-8?q?=20150+=20failures,=20add=2030+=20test=20files.=20schemas/model?= =?UTF-8?q?s=2099-100%,=20core/client=5Fregistry=20~98%,=20maintenance=209?= =?UTF-8?q?5-98%,=20git=20edges=2097%,=20translate=2098-100%,=20dashboard?= =?UTF-8?q?=20routes=2095-96%,=20dataset=5Freview=20100%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/:memory:test_auth | Bin 0 -> 999424 bytes backend/tests/api/test_agent_conversations.py | 16 +- .../api/test_dashboard_listing_routes.py | 159 ++++ .../api/test_dataset_review_deps_unit.py | 43 + backend/tests/api/test_llm.py | 11 +- backend/tests/api/test_mappings.py | 22 +- backend/tests/api/test_migration.py | 47 +- backend/tests/api/test_settings.py | 74 +- backend/tests/api/test_storage.py | 8 +- backend/tests/api/test_tasks.py | 34 +- .../tests/api/test_translate_job_routes.py | 107 ++- .../tests/api/test_translate_run_routes.py | 135 +++ .../test_validation_tasks_comprehensive.py | 60 +- .../test_client_datasets_edge.py | 243 ++++++ backend/tests/core/task_manager/test_graph.py | 203 +++++ .../translate/test_orchestrator_exec.py | 12 + .../test_preview_session_serializer.py | 126 +++ backend/tests/schemas/test_dataset_review.py | 510 +++++++++++ .../dataset_review/test_session_repository.py | 241 ++++++ .../tests/services/git/test_git_base_edge.py | 264 ++++++ .../tests/services/git/test_git_merge_edge.py | 264 ++++++ .../tests/services/git/test_git_sync_edge.py | 241 ++++++ .../tests/services/git/test_git_url_edge.py | 256 ++++++ .../maintenance/test_banner_renderer.py | 270 ++++++ .../maintenance/test_chart_manager.py | 304 +++++++ .../maintenance/test_dashboard_scanner.py | 266 ++++++ ...test_clean_release_dto_enums_exceptions.py | 433 ++++++++++ backend/tests/test_core/test_async_network.py | 9 + .../test_core/test_async_network_edge.py | 248 ++++++ .../tests/test_core/test_client_registry.py | 259 ++++++ .../test_core/test_connection_service_edge.py | 260 ++++++ backend/tests/test_core/test_logger_edge.py | 301 +++++++ .../tests/test_core/test_mapping_service.py | 32 +- .../test_core/test_risk_assessor_edge.py | 204 +++++ .../test_superset_compilation_adapter.py | 14 +- .../test_superset_compilation_adapter_edge.py | 185 ++++ backend/tests/test_models_auth_llm.py | 263 ++++++ .../tests/test_models_clean_release_ext.py | 791 ++++++++++++++++++ backend/tests/test_models_dashboard.py | 90 ++ backend/tests/test_models_filter_state.py | 244 ++++++ backend/tests/test_models_git.py | 163 ++++ backend/tests/test_models_translate.py | 430 ++++++++++ backend/tests/test_schemas_edge.py | 42 + 43 files changed, 7792 insertions(+), 92 deletions(-) create mode 100644 backend/:memory:test_auth create mode 100644 backend/tests/core/superset_client/test_client_datasets_edge.py create mode 100644 backend/tests/core/task_manager/test_graph.py create mode 100644 backend/tests/plugins/translate/test_preview_session_serializer.py create mode 100644 backend/tests/schemas/test_dataset_review.py create mode 100644 backend/tests/services/dataset_review/test_session_repository.py create mode 100644 backend/tests/services/git/test_git_base_edge.py create mode 100644 backend/tests/services/git/test_git_merge_edge.py create mode 100644 backend/tests/services/git/test_git_sync_edge.py create mode 100644 backend/tests/services/git/test_git_url_edge.py create mode 100644 backend/tests/services/maintenance/test_banner_renderer.py create mode 100644 backend/tests/services/maintenance/test_chart_manager.py create mode 100644 backend/tests/services/maintenance/test_dashboard_scanner.py create mode 100644 backend/tests/test_clean_release_dto_enums_exceptions.py create mode 100644 backend/tests/test_core/test_async_network_edge.py create mode 100644 backend/tests/test_core/test_client_registry.py create mode 100644 backend/tests/test_core/test_connection_service_edge.py create mode 100644 backend/tests/test_core/test_logger_edge.py create mode 100644 backend/tests/test_core/test_risk_assessor_edge.py create mode 100644 backend/tests/test_core/test_superset_compilation_adapter_edge.py create mode 100644 backend/tests/test_models_auth_llm.py create mode 100644 backend/tests/test_models_clean_release_ext.py create mode 100644 backend/tests/test_models_dashboard.py create mode 100644 backend/tests/test_models_filter_state.py create mode 100644 backend/tests/test_models_git.py create mode 100644 backend/tests/test_models_translate.py diff --git a/backend/:memory:test_auth b/backend/:memory:test_auth new file mode 100644 index 0000000000000000000000000000000000000000..974eb8163749e8c36059ad7fb87dd1bbda6c1ed5 GIT binary patch literal 999424 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 literal 0 HcmV?d00001 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 "