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