diff --git a/.opencode/skills/semantics-testing/SKILL.md b/.opencode/skills/semantics-testing/SKILL.md index 886ada3f..05e825ec 100644 --- a/.opencode/skills/semantics-testing/SKILL.md +++ b/.opencode/skills/semantics-testing/SKILL.md @@ -104,16 +104,28 @@ class TestDashboardMigration: ### Running tests ```bash -# All backend tests +# All backend tests (integration tests skipped by default) cd backend && source .venv/bin/activate && python -m pytest -v # Specific test file python -m pytest tests/test_migration.py -v +# Include integration tests (PostgreSQL/Superset Testcontainers) +python -m pytest --run-integration + +# Run only integration tests +python -m pytest tests/integration/ --run-integration + # With coverage python -m pytest --cov=src --cov-report=term-missing ``` +**Integration tests** (`tests/integration/`) use Testcontainers (PostgreSQL 16, Superset 4.1.2) +and require Docker. They are **skipped by default** — pass `--run-integration` to enable. +The `--run-integration` flag is registered in `backend/tests/conftest.py` via `pytest_addoption`; +skip logic lives in `backend/tests/integration/conftest.py` via `pytest_collection_modifyitems`. +See also: `backend/pyproject.toml` `[tool.pytest.ini_options] markers` for the registered marker. + ## VII. SVELTE / VITEST CONVENTIONS ### Test file structure diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 39834648..17f8101e 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,3 +17,6 @@ include = ["src*"] [tool.pytest.ini_options] pythonpath = ["."] asyncio_mode = "auto" +markers = [ + "integration: Integration tests requiring external services (Docker, Testcontainers, Superset). Use --run-integration to enable.", +] diff --git a/backend/src/api/routes/settings.py b/backend/src/api/routes/settings.py index 0cd87b54..1446557a 100755 --- a/backend/src/api/routes/settings.py +++ b/backend/src/api/routes/settings.py @@ -490,7 +490,13 @@ async def get_consolidated_settings( response_payload = ConsolidatedSettingsResponse( environments=[env.model_dump() for env in config.environments], - connections=[{**c.model_dump(), "password": "********"} for c in config.settings.connections], + connections=[ + { + **(c.model_dump() if not isinstance(c, dict) else c), + "password": "********", + } + for c in config.settings.connections + ], llm=normalized_llm, llm_providers=llm_providers_list, logging=config.settings.logging.model_dump(), @@ -534,13 +540,18 @@ async def update_consolidated_settings( # Update connections if provided if "connections" in settings_patch: + from ...core.config_models import DatabaseConnection as DBConnModel + cs = ConnectionService(config_manager) # Build lookup of existing connections by id to preserve encrypted passwords - existing_by_id = { - c.id: c.password - for c in current_settings.connections - } - processed = [] + # Guard: some items may still be dicts from a previous corruption — convert on the fly + existing_by_id: dict[str, str] = {} + for c in current_settings.connections: + if isinstance(c, dict): + existing_by_id[c.get("id", "")] = c.get("password", "") + else: + existing_by_id[c.id] = c.password + processed: list[DBConnModel] = [] for conn_dict in settings_patch["connections"]: conn_id = conn_dict.get("id", "") raw_pwd = conn_dict.get("password", "") @@ -549,7 +560,8 @@ async def update_consolidated_settings( conn_dict["password"] = existing_by_id[conn_id] elif raw_pwd and raw_pwd != "********": conn_dict["password"] = cs._encrypt_password(raw_pwd) - processed.append(conn_dict) + # Convert dict to DatabaseConnection model to preserve typed interface + processed.append(DBConnModel(**conn_dict)) current_settings.connections = processed # Update LLM if provided diff --git a/backend/tests/api/test_settings_consolidated.py b/backend/tests/api/test_settings_consolidated.py new file mode 100644 index 00000000..b255b14f --- /dev/null +++ b/backend/tests/api/test_settings_consolidated.py @@ -0,0 +1,386 @@ +#region TestSettingsConsolidated [C:3] [TYPE Module] [SEMANTICS test,settings,connections] +# @BRIEF Tests for GET/PATCH /api/settings/consolidated — connections typed-model enforcement. +# @RELATION BINDS_TO -> [get_consolidated_settings, update_consolidated_settings] +# @RELATION DEPENDS_ON -> [EXT:FastAPI:TestClient] +# @TEST_CONTRACT [GlobalSettings.connections] -> [ConsolidatedSettingsResponse | 200] +# @TEST_EDGE: dict_in_connections -> GET survives list[dict] in connections (no .model_dump crash) +# @TEST_EDGE: model_in_connections -> GET works with typed DatabaseConnection models +# @TEST_EDGE: empty_connections -> GET returns empty list +# @TEST_EDGE: patch_saves_as_model -> PATCH stores DatabaseConnection instances, not raw dicts +# @TEST_EDGE: patch_masked_password -> PATCH preserves existing password on "********" +# @TEST_EDGE: patch_new_password -> PATCH encrypts new passwords via ConnectionService +# @TEST_EDGE: unauthorized -> 403 when user lacks admin role +# @TEST_INVARIANT connections_typed -> VERIFIED_BY: patch_saves_as_model, patch_masked_password +# @INVARIANT current_settings.connections must always be list[DatabaseConnection], never list[dict] + +# Set required env vars before ANY app imports — crash-early guard for AuthConfig() +import os + +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing") +os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth_settings_consolidated") +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main_settings_consolidated") +os.environ.setdefault("DEV_MODE", "true") + +import pytest +from unittest.mock import MagicMock + +from fastapi.testclient import TestClient + +from src.app import app +from src.core.config_models import ( + DatabaseConnection, + FeaturesConfig, + GlobalSettings, + LoggingConfig, +) +from src.dependencies import ( + get_config_manager, + get_current_user, +) + +# ── Shared mocks ─────────────────────────────────────────────────────── + +mock_user = MagicMock() +mock_user.username = "testuser" +mock_user.roles = [] +admin_role = MagicMock() +admin_role.name = "Admin" +admin_role.permissions = [] +mock_user.roles.append(admin_role) +non_admin_role = MagicMock() +non_admin_role.name = "User" +non_admin_role.permissions = [] +non_admin_role.is_admin = False + + +def _make_conn_dict(**overrides) -> dict: + """Build a raw connection dict as the frontend would send it.""" + return { + "id": overrides.get("id", "conn-1"), + "name": overrides.get("name", "Test DB"), + "host": overrides.get("host", "localhost"), + "port": overrides.get("port", 5432), + "database": overrides.get("database", "testdb"), + "username": overrides.get("username", "admin"), + "password": overrides.get("password", "secret"), + "dialect": overrides.get("dialect", "postgresql"), + "extra_params": overrides.get("extra_params", {}), + "pool_size": overrides.get("pool_size", 5), + } + + +def _make_conn_model(**overrides) -> DatabaseConnection: + """Build a typed DatabaseConnection model.""" + return DatabaseConnection(**_make_conn_dict(**overrides)) + + +# ── Fixtures ─────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def mock_deps(): + """Override FastAPI dependencies for consolidated-settings routes. + + get_current_user returns an Admin user by default — permission checks + pass through the real has_permission function. + Sets up a real GlobalSettings so typed-field assignment is testable. + """ + mock_user.roles = [admin_role] + + # Real GlobalSettings with known state + real_settings = GlobalSettings() + real_settings.features = FeaturesConfig(translate=True, migration=False) + real_settings.logging = LoggingConfig() + + # AppConfig mock — .settings is a real GlobalSettings; + # .environments is empty to avoid MagicMock noise in responses + app_config = MagicMock() + app_config.settings = real_settings + app_config.environments = [] + + mock_config_manager = MagicMock() + mock_config_manager.get_config.return_value = app_config + + app.dependency_overrides[get_config_manager] = lambda: mock_config_manager + app.dependency_overrides[get_current_user] = lambda: mock_user + + yield { + "config": mock_config_manager, + "settings": real_settings, + } + app.dependency_overrides.clear() + + +client = TestClient(app) + + +# ── GET /api/settings/consolidated ───────────────────────────────────── + + +#region test_get_consolidated_with_model_connections [C:2] [TYPE Function] +# @BRIEF GET returns 200 when connections contains DatabaseConnection models. +# @TEST_INVARIANT connections_typed -> VERIFIED_BY: test_get_consolidated_with_model_connections +def test_get_consolidated_with_model_connections(mock_deps): + """DatabaseConnection models in settings.connections → 200, all passwords masked.""" + settings: GlobalSettings = mock_deps["settings"] + settings.connections = [ + _make_conn_model(id="c1", name="Alpha", password="real-pwd-1"), + _make_conn_model(id="c2", name="Beta", password="real-pwd-2"), + ] + + response = client.get("/api/settings/consolidated") + assert response.status_code == 200 + data = response.json() + + assert len(data["connections"]) == 2 + for entry in data["connections"]: + assert entry["password"] == "********", "Passwords must be masked in response" + assert data["connections"][0]["name"] == "Alpha" + assert data["connections"][1]["name"] == "Beta" + assert data["features"]["translate"] is True + assert data["features"]["migration"] is False +#endregion test_get_consolidated_with_model_connections + + +#region test_get_consolidated_with_dict_connections [C:2] [TYPE Function] +# @BRIEF GET handles defensive case where connections contains raw dicts from previous corruption. +# @TEST_EDGE: dict_in_connections -> GET does not crash +def test_get_consolidated_with_dict_connections(mock_deps): + """Raw dicts in settings.connections → 200 (no .model_dump crash).""" + settings: GlobalSettings = mock_deps["settings"] + settings.connections = [ # ← dicts, not DatabaseConnection models + {"id": "c1", "name": "Alpha", "password": "p1", "host": "h1", "port": 5432, "database": "d1", "username": "u1", "dialect": "postgresql"}, + {"id": "c2", "name": "Beta", "password": "p2", "host": "h2", "port": 5432, "database": "d2", "username": "u2", "dialect": "postgresql"}, + ] + + response = client.get("/api/settings/consolidated") + assert response.status_code == 200 + data = response.json() + + assert len(data["connections"]) == 2 + for entry in data["connections"]: + assert entry["password"] == "********" + assert data["connections"][0]["name"] == "Alpha" +#endregion test_get_consolidated_with_dict_connections + + +#region test_get_consolidated_empty_connections [C:2] [TYPE Function] +# @BRIEF GET returns empty list when no connections configured. +# @TEST_EDGE: empty_connections -> GET returns [] +def test_get_consolidated_empty_connections(mock_deps): + """Empty connections list → 200, connections=[], nothing crashes.""" + settings: GlobalSettings = mock_deps["settings"] + settings.connections = [] + + response = client.get("/api/settings/consolidated") + assert response.status_code == 200 + data = response.json() + assert data["connections"] == [] +#endregion test_get_consolidated_empty_connections + + +#region test_get_consolidated_unauthorized [C:2] [TYPE Function] +# @BRIEF GET returns 403 when user lacks admin:settings:READ permission. +# @TEST_EDGE: unauthorized -> 403 +def test_get_consolidated_unauthorized(): + """Non-admin user → 403.""" + mock_user.roles = [non_admin_role] + + response = client.get("/api/settings/consolidated") + assert response.status_code == 403 +#endregion test_get_consolidated_unauthorized + + +# ── PATCH /api/settings/consolidated ─────────────────────────────────── + + +#region test_patch_connections_saves_as_model [C:2] [TYPE Function] +# @BRIEF PATCH with connections dicts stores them as DatabaseConnection models. +# @TEST_INVARIANT connections_typed -> VERIFIED_BY: test_patch_connections_saves_as_model +def test_patch_connections_saves_as_model(mock_deps): + """After PATCH, current_settings.connections contains DatabaseConnection instances, not dicts.""" + mock_config = mock_deps["config"] + + payload = { + "connections": [ + _make_conn_dict(id="c1", name="Prod DB", password="p@ss"), + ] + } + response = client.patch("/api/settings/consolidated", json=payload) + assert response.status_code == 200 + + # Verify via update_global_settings call — connections must be typed + mock_config.update_global_settings.assert_called_once() + args, _ = mock_config.update_global_settings.call_args + updated_settings: GlobalSettings = args[0] + + assert len(updated_settings.connections) == 1 + conn = updated_settings.connections[0] + assert isinstance(conn, DatabaseConnection), ( + f"Expected DatabaseConnection, got {type(conn).__name__}" + ) + assert conn.id == "c1" + assert conn.name == "Prod DB" + assert conn.host == "localhost" + assert conn.dialect == "postgresql" +#endregion test_patch_connections_saves_as_model + + +#region test_patch_connections_masked_password [C:2] [TYPE Function] +# @BRIEF PATCH with "********" password preserves the existing encrypted password. +# @TEST_EDGE: patch_masked_password -> existing password not overwritten +def test_patch_connections_masked_password(mock_deps): + """Masked password "********" → keeps encrypted password from existing config.""" + settings: GlobalSettings = mock_deps["settings"] + settings.connections = [ + _make_conn_model(id="c-exist", name="Existing", password="encrypted-vault-value"), + ] + mock_config = mock_deps["config"] + + payload = { + "connections": [ + _make_conn_dict(id="c-exist", name="Existing", password="********"), + ] + } + response = client.patch("/api/settings/consolidated", json=payload) + assert response.status_code == 200 + + mock_config.update_global_settings.assert_called_once() + args, _ = mock_config.update_global_settings.call_args + updated_settings: GlobalSettings = args[0] + + conn = updated_settings.connections[0] + assert conn.password == "encrypted-vault-value", ( + "Must preserve existing password when masked password sent" + ) +#endregion test_patch_connections_masked_password + + +#region test_patch_connections_new_password_encrypted [C:2] [TYPE Function] +# @BRIEF PATCH with a new plaintext password encrypts it via ConnectionService. +# @TEST_EDGE: patch_new_password -> encrypts via ConnectionService._encrypt_password +def test_patch_connections_new_password_encrypted(mock_deps): + """New plaintext password → ConnectionService._encrypt_password called.""" + settings: GlobalSettings = mock_deps["settings"] + # Existing connection with an encrypted password + settings.connections = [ + _make_conn_model(id="c-old", name="Old Conn", password="stale-encrypted"), + ] + mock_config = mock_deps["config"] + + payload = { + "connections": [ + _make_conn_dict(id="c-old", name="Old Conn", password="new-plain-password"), + ] + } + response = client.patch("/api/settings/consolidated", json=payload) + assert response.status_code == 200 + + # Password is NOT "********" and NOT "stale-encrypted" — it goes through + # ConnectionService._encrypt_password which (without real EncryptionManager) + # returns the password as-is or encrypts it. At minimum we verify it changed. + mock_config.update_global_settings.assert_called_once() + args, _ = mock_config.update_global_settings.call_args + updated_settings: GlobalSettings = args[0] + + conn = updated_settings.connections[0] + assert conn.password != "********", "New password must not be masked" + assert conn.password != "stale-encrypted", "New password must replace old encrypted value" + assert conn.name == "Old Conn" +#endregion test_patch_connections_new_password_encrypted + + +#region test_patch_connections_multiple_at_once [C:2] [TYPE Function] +# @BRIEF PATCH processes multiple connections in one request. +def test_patch_connections_multiple_at_once(mock_deps): + """Three connections in one PATCH → all stored as DatabaseConnection models.""" + mock_config = mock_deps["config"] + + payload = { + "connections": [ + _make_conn_dict(id="c1", name="Alpha", password="p1"), + _make_conn_dict(id="c2", name="Beta", password="p2"), + _make_conn_dict(id="c3", name="Gamma", password="p3"), + ] + } + response = client.patch("/api/settings/consolidated", json=payload) + assert response.status_code == 200 + + mock_config.update_global_settings.assert_called_once() + args, _ = mock_config.update_global_settings.call_args + updated_settings: GlobalSettings = args[0] + + assert len(updated_settings.connections) == 3 + for conn in updated_settings.connections: + assert isinstance(conn, DatabaseConnection) + assert [c.name for c in updated_settings.connections] == ["Alpha", "Beta", "Gamma"] +#endregion test_patch_connections_multiple_at_once + + +#region test_patch_connections_empty_list [C:2] [TYPE Function] +# @BRIEF PATCH with empty connections list clears the list. +def test_patch_connections_empty_list(mock_deps): + """Empty connections list in PATCH → connections cleared.""" + settings: GlobalSettings = mock_deps["settings"] + settings.connections = [ + _make_conn_model(id="c1", name="To Delete", password="secret"), + ] + mock_config = mock_deps["config"] + + payload = {"connections": []} + response = client.patch("/api/settings/consolidated", json=payload) + assert response.status_code == 200 + + mock_config.update_global_settings.assert_called_once() + args, _ = mock_config.update_global_settings.call_args + updated_settings: GlobalSettings = args[0] + assert updated_settings.connections == [] +#endregion test_patch_connections_empty_list + + +#region test_patch_settings_without_connections [C:2] [TYPE Function] +# @BRIEF PATCH without connections only updates other settings (features, timezone). +def test_patch_settings_without_connections(mock_deps): + """No 'connections' key in payload → connections left untouched.""" + settings: GlobalSettings = mock_deps["settings"] + settings.connections = [ + _make_conn_model(id="c1", name="Existing Conn", password="secret"), + ] + mock_config = mock_deps["config"] + + payload = { + "features": {"translate": False, "migration": True}, + "app_timezone": "Asia/Tokyo", + } + response = client.patch("/api/settings/consolidated", json=payload) + assert response.status_code == 200 + + mock_config.update_global_settings.assert_called_once() + args, _ = mock_config.update_global_settings.call_args + updated_settings: GlobalSettings = args[0] + + # Connections preserved + assert len(updated_settings.connections) == 1 + assert updated_settings.connections[0].name == "Existing Conn" + + # Features updated + assert updated_settings.features.translate is False + assert updated_settings.features.migration is True + + # Timezone updated + assert updated_settings.app_timezone == "Asia/Tokyo" +#endregion test_patch_settings_without_connections + + +#region test_patch_unauthorized [C:2] [TYPE Function] +# @BRIEF PATCH returns 403 when user lacks admin:settings:WRITE permission. +# @TEST_EDGE: unauthorized -> 403 +def test_patch_unauthorized(): + """Non-admin user → 403.""" + mock_user.roles = [non_admin_role] + + payload = {"features": {"translate": False}} + response = client.patch("/api/settings/consolidated", json=payload) + assert response.status_code == 403 +#endregion test_patch_unauthorized + +#endregion TestSettingsConsolidated diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 796ca6f1..753f7891 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -64,6 +64,17 @@ def make_fk_engine(): return eng +# ── CLI options ── + +def pytest_addoption(parser): + parser.addoption( + "--run-integration", + action="store_true", + default=False, + help="Run integration tests (require Docker, Testcontainers, Superset container)", + ) + + # ── Test session lifecycle ── def pytest_configure(config): @@ -71,6 +82,10 @@ def pytest_configure(config): f"\n[conftest] SQLite (global: {_TEST_DB_PATH}) + FK enforcement", file=sys.stderr, ) + print( + " Integration tests skipped by default. Use --run-integration to enable.\n", + file=sys.stderr, + ) def pytest_unconfigure(config): diff --git a/backend/tests/core/superset_client/test_client_base.py b/backend/tests/core/superset_client/test_client_base.py new file mode 100644 index 00000000..b2f46856 --- /dev/null +++ b/backend/tests/core/superset_client/test_client_base.py @@ -0,0 +1,341 @@ +# #region Test.SupersetClient.Base [C:3] [TYPE Module] [SEMANTICS test,superset,client,base] +# @BRIEF Unit tests for SupersetClientBase — init, auth, pagination, import/export helpers. +# @RELATION BINDS_TO -> [SupersetClientBase] +# @TEST_EDGE: missing_file -> _do_import raises FileNotFoundError +# @TEST_EDGE: invalid_zip -> _validate_import_file raises SupersetAPIError +# @TEST_EDGE: non_zip_export -> _validate_export_response raises SupersetAPIError +# @TEST_EDGE: empty_export -> _validate_export_response raises SupersetAPIError +# @TEST_EDGE: unknown_resource_type -> get_all_resources returns empty list +import io +import zipfile +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from src.core.config_models import Environment +from src.core.superset_client._base import SupersetClientBase, DEFAULT_PAGE_SIZE +from src.core.utils.network import SupersetAPIError + + +def _make_env(**overrides) -> Environment: + defaults = dict(id="env1", name="TestEnv", url="http://superset.test", username="admin", password="admin") + defaults.update(overrides) + return Environment(**defaults) + + +def _make_client(env=None, mock_client=None) -> SupersetClientBase: + env = env or _make_env() + mc = mock_client or MagicMock() + mc.authenticate = AsyncMock(return_value={"access_token": "tok", "csrf_token": "csrf"}) + mc.get_headers = AsyncMock(return_value={"Authorization": "Bearer tok"}) + mc.fetch_paginated_count = AsyncMock(return_value=42) + mc.fetch_paginated_data = AsyncMock(return_value=[{"id": 1}, {"id": 2}]) + mc.upload_file = AsyncMock(return_value={"status": "ok"}) + mc.aclose = AsyncMock() + return SupersetClientBase(env, client=mc) + + +# #region test_init_stores_env_and_creates_client [C:2] [TYPE Function] +# @BRIEF __init__ stores env, creates AsyncAPIClient when none provided. +def test_init_stores_env_and_creates_client(): + env = _make_env() + with patch("src.core.superset_client._base.AsyncAPIClient") as MockCls: + mock_inst = MagicMock() + MockCls.return_value = mock_inst + obj = SupersetClientBase(env) + assert obj.env is env + assert obj.client is mock_inst + assert obj.network is mock_inst + assert obj.delete_before_reimport is False + MockCls.assert_called_once() +# #endregion test_init_stores_env_and_creates_client + +# #region test_init_uses_provided_client [C:2] [TYPE Function] +# @BRIEF __init__ uses injected client instead of creating a new one. +def test_init_uses_provided_client(): + env = _make_env() + injected = MagicMock() + obj = SupersetClientBase(env, client=injected) + assert obj.client is injected + assert obj.network is injected +# #endregion test_init_uses_provided_client + +# #region test_authenticate_delegates_to_client [C:2] [TYPE Function] +# @BRIEF authenticate() delegates to client.authenticate() and returns tokens. +@pytest.mark.asyncio +async def test_authenticate_delegates_to_client(): + obj = _make_client() + tokens = await obj.authenticate() + assert tokens == {"access_token": "tok", "csrf_token": "csrf"} + obj.client.authenticate.assert_awaited_once() +# #endregion test_authenticate_delegates_to_client + +# #region test_get_headers_delegates_to_client [C:2] [TYPE Function] +# @BRIEF get_headers() delegates to client.get_headers(). +@pytest.mark.asyncio +async def test_get_headers_delegates_to_client(): + obj = _make_client() + headers = await obj.get_headers() + assert headers == {"Authorization": "Bearer tok"} + obj.client.get_headers.assert_awaited_once() +# #endregion test_get_headers_delegates_to_client + +# #region test_validate_query_params_defaults [C:2] [TYPE Function] +# @BRIEF _validate_query_params applies default page=0 and page_size=100. +def test_validate_query_params_defaults(): + obj = _make_client() + result = obj._validate_query_params(None) + assert result == {"page": 0, "page_size": DEFAULT_PAGE_SIZE} +# #endregion test_validate_query_params_defaults + +# #region test_validate_query_params_merge [C:2] [TYPE Function] +# @BRIEF _validate_query_params merges caller overrides over defaults. +def test_validate_query_params_merge(): + obj = _make_client() + result = obj._validate_query_params({"page": 3, "columns": ["id"]}) + assert result["page"] == 3 + assert result["page_size"] == DEFAULT_PAGE_SIZE + assert result["columns"] == ["id"] +# #endregion test_validate_query_params_merge + +# #region test_fetch_total_object_count [C:2] [TYPE Function] +# @BRIEF _fetch_total_object_count delegates to client.fetch_paginated_count. +@pytest.mark.asyncio +async def test_fetch_total_object_count(): + obj = _make_client() + count = await obj._fetch_total_object_count("/dashboard/") + assert count == 42 + obj.client.fetch_paginated_count.assert_awaited_once_with( + endpoint="/dashboard/", + query_params={"page": 0, "page_size": 1}, + count_field="count", + ) +# #endregion test_fetch_total_object_count + +# #region test_fetch_all_pages [C:2] [TYPE Function] +# @BRIEF _fetch_all_pages delegates to client.fetch_paginated_data. +@pytest.mark.asyncio +async def test_fetch_all_pages(): + obj = _make_client() + opts = {"base_query": {"page": 0}, "results_field": "result"} + data = await obj._fetch_all_pages("/chart/", opts) + assert data == [{"id": 1}, {"id": 2}] + obj.client.fetch_paginated_data.assert_awaited_once_with( + endpoint="/chart/", pagination_options=opts + ) +# #endregion test_fetch_all_pages + +# #region test_do_import_success [C:2] [TYPE Function] +# @BRIEF _do_import uploads an existing file via client.upload_file. +@pytest.mark.asyncio +async def test_do_import_success(tmp_path): + obj = _make_client() + fake_file = tmp_path / "export.zip" + fake_file.write_bytes(b"PK\x03\x04fake") + result = await obj._do_import(str(fake_file)) + assert result == {"status": "ok"} + obj.client.upload_file.assert_awaited_once() +# #endregion test_do_import_success + +# #region test_do_import_file_not_found [C:2] [TYPE Function] +# @BRIEF _do_import raises FileNotFoundError when file does not exist. +@pytest.mark.asyncio +async def test_do_import_file_not_found(): + obj = _make_client() + with pytest.raises(FileNotFoundError, match="does_not_exist.zip"): + await obj._do_import("/tmp/does_not_exist.zip") +# #endregion test_do_import_file_not_found + +# #region test_validate_export_response_success [C:2] [TYPE Function] +# @BRIEF _validate_export_response passes for valid ZIP content-type with body. +def test_validate_export_response_success(): + obj = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.headers = {"Content-Type": "application/zip"} + resp.content = b"PK\x03\x04data" + obj._validate_export_response(resp, 1) # no exception +# #endregion test_validate_export_response_success + +# #region test_validate_export_response_not_zip [C:2] [TYPE Function] +# @BRIEF _validate_export_response raises SupersetAPIError for non-ZIP content-type. +def test_validate_export_response_not_zip(): + obj = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.headers = {"Content-Type": "application/json"} + resp.content = b'{"error": true}' + with pytest.raises(SupersetAPIError, match="не ZIP"): + obj._validate_export_response(resp, 1) +# #endregion test_validate_export_response_not_zip + +# #region test_validate_export_response_empty [C:2] [TYPE Function] +# @BRIEF _validate_export_response raises SupersetAPIError for empty body. +def test_validate_export_response_empty(): + obj = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.headers = {"Content-Type": "application/zip"} + resp.content = b"" + with pytest.raises(SupersetAPIError, match="пустые"): + obj._validate_export_response(resp, 1) +# #endregion test_validate_export_response_empty + +# #region test_resolve_export_filename_from_header [C:2] [TYPE Function] +# @BRIEF _resolve_export_filename extracts filename from Content-Disposition header. +def test_resolve_export_filename_from_header(): + obj = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.headers = {"Content-Disposition": 'attachment; filename="my_dash.zip"'} + fname = obj._resolve_export_filename(resp, 99) + assert fname == "my_dash.zip" +# #endregion test_resolve_export_filename_from_header + +# #region test_resolve_export_filename_fallback [C:2] [TYPE Function] +# @BRIEF _resolve_export_filename generates timestamped name when header is absent. +def test_resolve_export_filename_fallback(): + obj = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.headers = {} + fname = obj._resolve_export_filename(resp, 42) + assert fname.startswith("dashboard_export_42_") + assert fname.endswith(".zip") +# #endregion test_resolve_export_filename_fallback + +# #region test_validate_import_file_valid [C:2] [TYPE Function] +# @BRIEF _validate_import_file passes for a valid ZIP containing metadata.yaml. +def test_validate_import_file_valid(tmp_path): + obj = _make_client() + zip_path = tmp_path / "valid.zip" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("dashboard_1/metadata.yaml", "title: test") + zip_path.write_bytes(buf.getvalue()) + obj._validate_import_file(str(zip_path)) # no exception +# #endregion test_validate_import_file_valid + +# #region test_validate_import_file_not_zip [C:2] [TYPE Function] +# @BRIEF _validate_import_file raises SupersetAPIError for non-ZIP file. +def test_validate_import_file_not_zip(tmp_path): + obj = _make_client() + bad = tmp_path / "bad.zip" + bad.write_bytes(b"not a zip file") + with pytest.raises(SupersetAPIError, match="не является ZIP"): + obj._validate_import_file(str(bad)) +# #endregion test_validate_import_file_not_zip + +# #region test_validate_import_file_missing_metadata [C:2] [TYPE Function] +# @BRIEF _validate_import_file raises SupersetAPIError when metadata.yaml is absent. +def test_validate_import_file_missing_metadata(tmp_path): + obj = _make_client() + zip_path = tmp_path / "no_meta.zip" + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("dashboard_1/charts.yaml", "data: x") + zip_path.write_bytes(buf.getvalue()) + with pytest.raises(SupersetAPIError, match="metadata.yaml"): + obj._validate_import_file(str(zip_path)) +# #endregion test_validate_import_file_missing_metadata + +# #region test_validate_import_file_not_found [C:2] [TYPE Function] +# @BRIEF _validate_import_file raises FileNotFoundError for missing path. +def test_validate_import_file_not_found(): + obj = _make_client() + with pytest.raises(FileNotFoundError): + obj._validate_import_file("/tmp/nonexistent_file.zip") +# #endregion test_validate_import_file_not_found + +# #region test_resolve_target_id_by_direct_id [C:2] [TYPE Function] +# @BRIEF _resolve_target_id_for_delete returns dash_id directly when provided. +@pytest.mark.asyncio +async def test_resolve_target_id_by_direct_id(): + obj = _make_client() + result = await obj._resolve_target_id_for_delete(dash_id=10, dash_slug=None) + assert result == 10 +# #endregion test_resolve_target_id_by_direct_id + +# #region test_resolve_target_id_by_slug [C:2] [TYPE Function] +# @BRIEF _resolve_target_id_for_delete resolves slug via get_dashboards. +@pytest.mark.asyncio +async def test_resolve_target_id_by_slug(): + obj = _make_client() + obj.get_dashboards = AsyncMock(return_value=(1, [{"id": 77}])) + result = await obj._resolve_target_id_for_delete(dash_id=None, dash_slug="my-slug") + assert result == 77 +# #endregion test_resolve_target_id_by_slug + +# #region test_resolve_target_id_slug_not_found [C:2] [TYPE Function] +# @BRIEF _resolve_target_id_for_delete returns None when slug resolves to nothing. +@pytest.mark.asyncio +async def test_resolve_target_id_slug_not_found(): + obj = _make_client() + obj.get_dashboards = AsyncMock(return_value=(0, [])) + result = await obj._resolve_target_id_for_delete(dash_id=None, dash_slug="nope") + assert result is None +# #endregion test_resolve_target_id_slug_not_found + +# #region test_resolve_target_id_slug_exception [C:2] [TYPE Function] +# @BRIEF _resolve_target_id_for_delete returns None when slug lookup raises. +@pytest.mark.asyncio +async def test_resolve_target_id_slug_exception(): + obj = _make_client() + obj.get_dashboards = AsyncMock(side_effect=RuntimeError("network down")) + result = await obj._resolve_target_id_for_delete(dash_id=None, dash_slug="oops") + assert result is None +# #endregion test_resolve_target_id_slug_exception + +# #region test_resolve_target_id_both_none [C:2] [TYPE Function] +# @BRIEF _resolve_target_id_for_delete returns None when both args are None. +@pytest.mark.asyncio +async def test_resolve_target_id_both_none(): + obj = _make_client() + result = await obj._resolve_target_id_for_delete(dash_id=None, dash_slug=None) + assert result is None +# #endregion test_resolve_target_id_both_none + +# #region test_get_all_resources_chart [C:2] [TYPE Function] +# @BRIEF get_all_resources fetches chart resources with correct columns. +@pytest.mark.asyncio +async def test_get_all_resources_chart(): + obj = _make_client() + data = await obj.get_all_resources("chart") + assert len(data) == 2 + call_kwargs = obj.client.fetch_paginated_data.call_args + assert call_kwargs.kwargs["endpoint"] == "/chart/" + base_query = call_kwargs.kwargs["pagination_options"]["base_query"] + assert "id" in base_query["columns"] + assert "uuid" in base_query["columns"] +# #endregion test_get_all_resources_chart + +# #region test_get_all_resources_unknown_type [C:2] [TYPE Function] +# @BRIEF get_all_resources returns empty list for unknown resource type. +@pytest.mark.asyncio +async def test_get_all_resources_unknown_type(): + obj = _make_client() + data = await obj.get_all_resources("unknown_type") + assert data == [] +# #endregion test_get_all_resources_unknown_type + +# #region test_get_all_resources_with_since [C:2] [TYPE Function] +# @BRIEF get_all_resources adds timestamp filter when since_dttm is provided. +@pytest.mark.asyncio +async def test_get_all_resources_with_since(): + obj = _make_client() + since = datetime(2025, 1, 1, tzinfo=timezone.utc) + await obj.get_all_resources("dashboard", since_dttm=since) + call_kwargs = obj.client.fetch_paginated_data.call_args + base_query = call_kwargs.kwargs["pagination_options"]["base_query"] + assert "filters" in base_query + assert base_query["filters"][0]["col"] == "changed_on_dttm" +# #endregion test_get_all_resources_with_since + +# #region test_aclose_delegates [C:2] [TYPE Function] +# @BRIEF aclose() delegates to client.aclose(). +@pytest.mark.asyncio +async def test_aclose_delegates(): + obj = _make_client() + await obj.aclose() + obj.client.aclose.assert_awaited_once() +# #endregion test_aclose_delegates + +# #endregion Test.SupersetClient.Base diff --git a/backend/tests/core/superset_client/test_client_charts.py b/backend/tests/core/superset_client/test_client_charts.py new file mode 100644 index 00000000..090adc74 --- /dev/null +++ b/backend/tests/core/superset_client/test_client_charts.py @@ -0,0 +1,327 @@ +# #region Test.SupersetClient.Charts [C:3] [TYPE Module] [SEMANTICS test,superset,chart,database] +# @BRIEF Unit tests for chart and database mixins — get, list, layout extraction, CRUD. +# @RELATION BINDS_TO -> [SupersetChartsMixin] +# @RELATION BINDS_TO -> [SupersetDatabasesMixin] +# @TEST_EDGE: empty_charts -> get_charts returns (0, []) +# @TEST_EDGE: unknown_chart_id_key -> _extract_chart_ids_from_layout skips unparseable values +# @TEST_EDGE: empty_databases -> get_databases returns (0, []) +# @TEST_EDGE: database_uuid_not_found -> get_database_by_uuid returns None +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.core.config_models import Environment +from src.core.superset_client import SupersetClient + + +def _make_env() -> Environment: + return Environment(id="env1", name="T", url="http://test.local", username="u", password="p") + + +def _make_client() -> SupersetClient: + env = _make_env() + client = SupersetClient(env) + mc = MagicMock() + mc.request = AsyncMock(return_value={}) + mc.fetch_paginated_data = AsyncMock(return_value=[]) + client.client = mc + client.network = mc + return client + + +# ── Charts: get_chart ─────────────────────────────────────────────────────── + +# #region test_get_chart_success [C:2] [TYPE Function] +# @BRIEF get_chart returns the response dict for a valid chart ID. +@pytest.mark.asyncio +async def test_get_chart_success(): + client = _make_client() + expected = {"result": {"id": 7, "slice_name": "Revenue"}} + client.client.request = AsyncMock(return_value=expected) + result = await client.get_chart(7) + assert result == expected + client.client.request.assert_awaited_once_with(method="GET", endpoint="/chart/7") +# #endregion test_get_chart_success + +# #region test_get_chart_not_found [C:2] [TYPE Function] +# @BRIEF get_chart propagates error when chart does not exist (404 from API). +@pytest.mark.asyncio +async def test_get_chart_not_found(): + client = _make_client() + client.client.request = AsyncMock(side_effect=Exception("404 Not Found")) + with pytest.raises(Exception, match="404"): + await client.get_chart(9999) +# #endregion test_get_chart_not_found + +# ── Charts: get_charts ────────────────────────────────────────────────────── + +# #region test_get_charts_success [C:2] [TYPE Function] +# @BRIEF get_charts returns (count, data) from paginated fetch. +@pytest.mark.asyncio +async def test_get_charts_success(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[ + {"id": 1, "slice_name": "A"}, {"id": 2, "slice_name": "B"}, + ]) + count, data = await client.get_charts() + assert count == 2 + assert data[0]["slice_name"] == "A" +# #endregion test_get_charts_success + +# #region test_get_charts_empty [C:2] [TYPE Function] +# @BRIEF get_charts returns (0, []) when no charts exist. +@pytest.mark.asyncio +async def test_get_charts_empty(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[]) + count, data = await client.get_charts() + assert count == 0 + assert data == [] +# #endregion test_get_charts_empty + +# #region test_get_charts_custom_columns [C:2] [TYPE Function] +# @BRIEF get_charts passes custom columns through to pagination query. +@pytest.mark.asyncio +async def test_get_charts_custom_columns(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[{"id": 1}]) + await client.get_charts(query={"columns": ["id", "slice_name", "viz_type"]}) + call_kwargs = client.client.fetch_paginated_data.call_args + base_query = call_kwargs.kwargs["pagination_options"]["base_query"] + assert base_query["columns"] == ["id", "slice_name", "viz_type"] +# #endregion test_get_charts_custom_columns + +# ── Charts: _extract_chart_ids_from_layout ────────────────────────────────── + +# #region test_extract_chart_ids_chartId_key [C:2] [TYPE Function] +# @BRIEF _extract_chart_ids_from_layout finds IDs via 'chartId' key. +def test_extract_chart_ids_chartId_key(): + client = _make_client() + layout = {"row1": {"chartId": 10}, "row2": {"chartId": 20}} + ids = client._extract_chart_ids_from_layout(layout) + assert ids == {10, 20} +# #endregion test_extract_chart_ids_chartId_key + +# #region test_extract_chart_ids_chart_id_key [C:2] [TYPE Function] +# @BRIEF _extract_chart_ids_from_layout finds IDs via 'chart_id' key. +def test_extract_chart_ids_chart_id_key(): + client = _make_client() + layout = {"a": {"chart_id": 5}} + ids = client._extract_chart_ids_from_layout(layout) + assert ids == {5} +# #endregion test_extract_chart_ids_chart_id_key + +# #region test_extract_chart_ids_slice_id_key [C:2] [TYPE Function] +# @BRIEF _extract_chart_ids_from_layout finds IDs via 'slice_id' and 'sliceId' keys. +def test_extract_chart_ids_slice_id_key(): + client = _make_client() + layout = {"x": {"slice_id": 3}, "y": {"sliceId": 4}} + ids = client._extract_chart_ids_from_layout(layout) + assert ids == {3, 4} +# #endregion test_extract_chart_ids_slice_id_key + +# #region test_extract_chart_ids_CHART_dash_N_pattern [C:2] [TYPE Function] +# @BRIEF _extract_chart_ids_from_layout extracts numeric IDs from 'CHART-N' string ids. +def test_extract_chart_ids_CHART_dash_N_pattern(): + client = _make_client() + layout = {"a": {"id": "CHART-42"}, "b": {"id": "CHART-7"}, "c": {"id": "OTHER-1"}} + ids = client._extract_chart_ids_from_layout(layout) + assert ids == {42, 7} +# #endregion test_extract_chart_ids_CHART_dash_N_pattern + +# #region test_extract_chart_ids_nested [C:2] [TYPE Function] +# @BRIEF _extract_chart_ids_from_layout traverses deeply nested structures. +def test_extract_chart_ids_nested(): + client = _make_client() + layout = { + "rows": [ + {"cols": [{"chartId": 1}, {"chartId": 2}]}, + {"cols": [{"chartId": 3}]}, + ] + } + ids = client._extract_chart_ids_from_layout(layout) + assert ids == {1, 2, 3} +# #endregion test_extract_chart_ids_nested + +# #region test_extract_chart_ids_empty [C:2] [TYPE Function] +# @BRIEF _extract_chart_ids_from_layout returns empty set for empty payload. +def test_extract_chart_ids_empty(): + client = _make_client() + assert client._extract_chart_ids_from_layout({}) == set() + assert client._extract_chart_ids_from_layout([]) == set() +# #endregion test_extract_chart_ids_empty + +# #region test_extract_chart_ids_unparseable [C:2] [TYPE Function] +# @BRIEF _extract_chart_ids_from_layout skips unparseable chart ID values. +def test_extract_chart_ids_unparseable(): + client = _make_client() + layout = {"a": {"chartId": "not_a_number"}, "b": {"chartId": 99}} + ids = client._extract_chart_ids_from_layout(layout) + assert ids == {99} +# #endregion test_extract_chart_ids_unparseable + +# #region test_extract_chart_ids_non_dict_input [C:2] [TYPE Function] +# @BRIEF _extract_chart_ids_from_layout handles scalar input gracefully. +def test_extract_chart_ids_non_dict_input(): + client = _make_client() + assert client._extract_chart_ids_from_layout("string") == set() + assert client._extract_chart_ids_from_layout(42) == set() + assert client._extract_chart_ids_from_layout(None) == set() +# #endregion test_extract_chart_ids_non_dict_input + +# ── Databases: get_databases ──────────────────────────────────────────────── + +# #region test_get_databases_success [C:2] [TYPE Function] +# @BRIEF get_databases returns (count, data) from paginated fetch. +@pytest.mark.asyncio +async def test_get_databases_success(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[ + {"id": 1, "database_name": "pg"}, {"id": 2, "database_name": "mysql"}, + ]) + count, data = await client.get_databases() + assert count == 2 + assert data[0]["database_name"] == "pg" +# #endregion test_get_databases_success + +# #region test_get_databases_empty [C:2] [TYPE Function] +# @BRIEF get_databases returns (0, []) when no databases exist. +@pytest.mark.asyncio +async def test_get_databases_empty(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[]) + count, data = await client.get_databases() + assert count == 0 + assert data == [] +# #endregion test_get_databases_empty + +# ── Databases: get_database ───────────────────────────────────────────────── + +# #region test_get_database_success [C:2] [TYPE Function] +# @BRIEF get_database returns response dict for a valid database ID. +@pytest.mark.asyncio +async def test_get_database_success(): + client = _make_client() + expected = {"id": 3, "database_name": "pg_main", "backend": "postgresql"} + client.client.request = AsyncMock(return_value=expected) + result = await client.get_database(3) + assert result == expected + client.client.request.assert_awaited_once_with(method="GET", endpoint="/database/3") +# #endregion test_get_database_success + +# #region test_get_database_not_found [C:2] [TYPE Function] +# @BRIEF get_database propagates error for non-existent database ID. +@pytest.mark.asyncio +async def test_get_database_not_found(): + client = _make_client() + client.client.request = AsyncMock(side_effect=Exception("404 Not Found")) + with pytest.raises(Exception, match="404"): + await client.get_database(9999) +# #endregion test_get_database_not_found + +# ── Databases: get_databases_summary ──────────────────────────────────────── + +# #region test_get_databases_summary [C:2] [TYPE Function] +# @BRIEF get_databases_summary renames 'backend' key to 'engine'. +@pytest.mark.asyncio +async def test_get_databases_summary(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[ + {"id": 1, "uuid": "abc", "database_name": "pg", "backend": "postgresql"}, + ]) + result = await client.get_databases_summary() + assert len(result) == 1 + assert result[0]["engine"] == "postgresql" + assert "backend" not in result[0] +# #endregion test_get_databases_summary + +# #region test_get_databases_summary_empty [C:2] [TYPE Function] +# @BRIEF get_databases_summary returns empty list when no databases. +@pytest.mark.asyncio +async def test_get_databases_summary_empty(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[]) + result = await client.get_databases_summary() + assert result == [] +# #endregion test_get_databases_summary_empty + +# ── Databases: get_database_by_uuid ───────────────────────────────────────── + +# #region test_get_database_by_uuid_found [C:2] [TYPE Function] +# @BRIEF get_database_by_uuid returns the first match. +@pytest.mark.asyncio +async def test_get_database_by_uuid_found(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[ + {"id": 5, "uuid": "target-uuid", "database_name": "found"}, + ]) + result = await client.get_database_by_uuid("target-uuid") + assert result["id"] == 5 +# #endregion test_get_database_by_uuid_found + +# #region test_get_database_by_uuid_not_found [C:2] [TYPE Function] +# @BRIEF get_database_by_uuid returns None when no match. +@pytest.mark.asyncio +async def test_get_database_by_uuid_not_found(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[]) + result = await client.get_database_by_uuid("nonexistent-uuid") + assert result is None +# #endregion test_get_database_by_uuid_not_found + +# ── Databases: create_database ────────────────────────────────────────────── + +# #region test_create_database_success [C:2] [TYPE Function] +# @BRIEF create_database sends POST with correct payload and returns response. +@pytest.mark.asyncio +async def test_create_database_success(): + client = _make_client() + client.client.request = AsyncMock(return_value={"id": 10, "result": "created"}) + result = await client.create_database("mydb", "postgresql://localhost/mydb") + assert result["id"] == 10 + call_kwargs = client.client.request.call_args + assert call_kwargs.kwargs["method"] == "POST" + assert call_kwargs.kwargs["endpoint"] == "/database/" + data = call_kwargs.kwargs["data"] + assert data["database_name"] == "mydb" + assert data["sqlalchemy_uri"] == "postgresql://localhost/mydb" + assert data["expose_in_sqllab"] is True + assert data["allow_dml"] is False +# #endregion test_create_database_success + +# #region test_create_database_custom_flags [C:2] [TYPE Function] +# @BRIEF create_database passes custom expose_in_sqllab and allow_dml flags. +@pytest.mark.asyncio +async def test_create_database_custom_flags(): + client = _make_client() + client.client.request = AsyncMock(return_value={"id": 11}) + await client.create_database("db2", "sqlite:///test.db", expose_in_sqllab=False, allow_dml=True) + data = client.client.request.call_args.kwargs["data"] + assert data["expose_in_sqllab"] is False + assert data["allow_dml"] is True +# #endregion test_create_database_custom_flags + +# ── Databases: delete_database ────────────────────────────────────────────── + +# #region test_delete_database_success [C:2] [TYPE Function] +# @BRIEF delete_database sends DELETE request and returns response. +@pytest.mark.asyncio +async def test_delete_database_success(): + client = _make_client() + client.client.request = AsyncMock(return_value={"result": "deleted"}) + result = await client.delete_database(7) + assert result == {"result": "deleted"} + client.client.request.assert_awaited_once_with(method="DELETE", endpoint="/database/7") +# #endregion test_delete_database_success + +# #region test_delete_database_not_found [C:2] [TYPE Function] +# @BRIEF delete_database propagates error for non-existent database. +@pytest.mark.asyncio +async def test_delete_database_not_found(): + client = _make_client() + client.client.request = AsyncMock(side_effect=Exception("404")) + with pytest.raises(Exception, match="404"): + await client.delete_database(9999) +# #endregion test_delete_database_not_found + +# #endregion Test.SupersetClient.Charts diff --git a/backend/tests/core/superset_client/test_client_dashboards.py b/backend/tests/core/superset_client/test_client_dashboards.py new file mode 100644 index 00000000..8c29f60d --- /dev/null +++ b/backend/tests/core/superset_client/test_client_dashboards.py @@ -0,0 +1,395 @@ +# #region Test.SupersetClient.DashboardsCrud [C:3] [TYPE Module] [SEMANTICS test,superset,dashboard,crud] +# @BRIEF Unit tests for dashboard CRUD mixin — detail, export, import, delete. +# @RELATION BINDS_TO -> [SupersetDashboardsCrudMixin] +# @TEST_EDGE: charts_fetch_fails -> get_dashboard_detail falls back to layout extraction +# @TEST_EDGE: import_fails_no_delete -> import_dashboard re-raises when delete_before_reimport is False +# @TEST_EDGE: import_fails_retry -> import_dashboard deletes then retries when delete_before_reimport is True +import io, json, zipfile +from unittest.mock import AsyncMock, MagicMock +import httpx, pytest +from src.core.config_models import Environment +from src.core.superset_client import SupersetClient +from src.core.utils.network import SupersetAPIError + +def _make_client() -> SupersetClient: + env = Environment(id="env1", name="T", url="http://test.local", username="u", password="p") + client = SupersetClient(env) + mc = MagicMock() + mc.request = AsyncMock(return_value={}) + mc.fetch_paginated_data = AsyncMock(return_value=[]) + mc.upload_file = AsyncMock(return_value={"status": "ok"}) + client.client = mc; client.network = mc + return client + +def _detail_mock(client, charts_resp, datasets_resp): + async def mock_req(method, endpoint, **kw): + if "/charts" in endpoint: return charts_resp + if "/datasets" in endpoint: return datasets_resp + return {} + client.client.request = AsyncMock(side_effect=mock_req) + +def _zip_with_metadata(tmp_path, name="import.zip"): + p = tmp_path / name + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + zf.writestr("dash/metadata.yaml", "title: x") + p.write_bytes(buf.getvalue()) + return p + +# ── get_dashboard_detail ──────────────────────────────────────────────────── +# #region test_detail_success [C:2] [TYPE Function] +# @BRIEF Happy path: dashboard with charts and datasets from API responses. +@pytest.mark.asyncio +async def test_detail_success(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": { + "id": 10, "dashboard_title": "My Dash", "slug": "my-dash", "url": "/dash/10", + "description": "desc", "published": True, "changed_on_utc": "2025-01-01", + "position_json": "", "json_metadata": "", + }}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, + charts_resp={"result": [{"id": 100, "slice_name": "Chart A", + "form_data": '{"viz_type":"table","datasource":"42__table"}', + "datasource_id": None, "changed_on": "2025-01-02", "description": "A chart"}]}, + datasets_resp={"result": [{"id": 42, "table_name": "orders", "schema": "public", + "database": {"database_name": "pg_main"}, "changed_on": "2025-01-03"}]}) + r = await c.get_dashboard_detail(10) + assert r["id"] == 10 and r["title"] == "My Dash" + assert r["chart_count"] == 1 and r["charts"][0]["dataset_id"] == 42 + assert r["dataset_count"] == 1 and r["datasets"][0]["table_name"] == "orders" +# #endregion test_detail_success + +# #region test_detail_charts_fail_fallback [C:2] [TYPE Function] +# @BRIEF When charts endpoint fails, falls back to layout-based chart extraction. +@pytest.mark.asyncio +async def test_detail_charts_fail_fallback(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": { + "id": 20, "dashboard_title": "FB", "position_json": json.dumps({"CHART-5": {"chartId": 5}, "CHART-9": {"chartId": 9}}), + "json_metadata": "", "changed_on_utc": "2025-01-01"}}) + c.get_chart = AsyncMock(side_effect=[ + {"result": {"id": 5, "slice_name": "C5", "viz_type": "bar", "datasource_id": 1, "changed_on": "t", "description": "d"}}, + {"result": {"id": 9, "slice_name": "C9", "viz_type": "line", "datasource_id": 2, "changed_on": "t", "description": "d"}}]) + c.get_dataset = AsyncMock(return_value={"result": {"id": 1, "table_name": "t1", "schema": None, "database": {"database_name": "db"}, "changed_on": "t"}}) + c.client.request = AsyncMock(side_effect=RuntimeError("net err")) + r = await c.get_dashboard_detail(20) + assert {ch["id"] for ch in r["charts"]} == {5, 9} +# #endregion test_detail_charts_fail_fallback + +# #region test_detail_empty [C:2] [TYPE Function] +# @BRIEF Dashboard with no charts/datasets returns empty lists. +@pytest.mark.asyncio +async def test_detail_empty(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 30, "dashboard_title": "E", "position_json": "", "json_metadata": ""}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + c.client.request = AsyncMock(return_value={"result": []}) + r = await c.get_dashboard_detail(30) + assert r["charts"] == [] and r["datasets"] == [] and r["chart_count"] == 0 +# #endregion test_detail_empty + +# #region test_detail_backfill_datasets [C:2] [TYPE Function] +# @BRIEF Datasets referenced by charts but missing from datasets endpoint are backfilled. +@pytest.mark.asyncio +async def test_detail_backfill_datasets(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 40, "dashboard_title": "BF"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock(return_value={"result": { + "id": 99, "table_name": "backfilled", "schema": "s", "database": {"database_name": "db"}, "changed_on_utc": "t"}}) + _detail_mock(c, charts_resp={"result": [{"id": 1, "slice_name": "C1", "form_data": {"datasource": {"id": 99}}, "datasource_id": 99}]}, + datasets_resp={"result": []}) + r = await c.get_dashboard_detail(40) + assert any(d["id"] == 99 for d in r["datasets"]) +# #endregion test_detail_backfill_datasets + +# ── extract_dataset_id edge cases ─────────────────────────────────────────── +# #region test_detail_form_data_not_dict [C:2] [TYPE Function] +# @BRIEF Non-dict form_data yields dataset_id=None. +@pytest.mark.asyncio +async def test_detail_form_data_not_dict(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 50, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": [{"id": 10, "slice_name": "C", "form_data": None, "datasource_id": None}]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(50))["charts"][0]["dataset_id"] is None +# #endregion test_detail_form_data_not_dict + +# #region test_detail_form_data_dict_datasource [C:2] [TYPE Function] +# @BRIEF Dict datasource extracts id correctly. +@pytest.mark.asyncio +async def test_detail_form_data_dict_datasource(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 51, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": [{"id": 11, "slice_name": "C", "form_data": {"datasource": {"id": 77}}, "datasource_id": None}]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(51))["charts"][0]["dataset_id"] == 77 +# #endregion test_detail_form_data_dict_datasource + +# #region test_detail_form_data_datasource_id_fallback [C:2] [TYPE Function] +# @BRIEF datasource_id field (no datasource key) extracts id. +@pytest.mark.asyncio +async def test_detail_form_data_datasource_id_fallback(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 52, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": [{"id": 12, "slice_name": "C", "form_data": {"datasource_id": 88}, "datasource_id": None}]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(52))["charts"][0]["dataset_id"] == 88 +# #endregion test_detail_form_data_datasource_id_fallback + +# #region test_detail_chart_obj_not_dict [C:2] [TYPE Function] +# @BRIEF Non-dict chart entries are skipped. +@pytest.mark.asyncio +async def test_detail_chart_obj_not_dict(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 53, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": ["not_a_dict", 42, None]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(53))["charts"] == [] +# #endregion test_detail_chart_obj_not_dict + +# #region test_detail_chart_id_none [C:2] [TYPE Function] +# @BRIEF Chart entries with no id field are skipped. +@pytest.mark.asyncio +async def test_detail_chart_id_none(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 54, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": [{"slice_name": "NoID"}]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(54))["charts"] == [] +# #endregion test_detail_chart_id_none + +# #region test_detail_form_data_bad_json [C:2] [TYPE Function] +# @BRIEF Invalid JSON form_data falls back to empty dict. +@pytest.mark.asyncio +async def test_detail_form_data_bad_json(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 55, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": [{"id": 15, "slice_name": "C", "form_data": "{bad", "datasource_id": 5}]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(55))["charts"][0]["dataset_id"] == 5 +# #endregion test_detail_form_data_bad_json + +# #region test_detail_dataset_obj_not_dict [C:2] [TYPE Function] +# @BRIEF Non-dict dataset entries are skipped. +@pytest.mark.asyncio +async def test_detail_dataset_obj_not_dict(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 56, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + c.client.request = AsyncMock(return_value={"result": ["not_dict"]}) + assert (await c.get_dashboard_detail(56))["datasets"] == [] +# #endregion test_detail_dataset_obj_not_dict + +# #region test_detail_dataset_id_none [C:2] [TYPE Function] +# @BRIEF Dataset entries with no id field are skipped. +@pytest.mark.asyncio +async def test_detail_dataset_id_none(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 57, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + c.client.request = AsyncMock(return_value={"result": [{"table_name": "no_id"}]}) + assert (await c.get_dashboard_detail(57))["datasets"] == [] +# #endregion test_detail_dataset_id_none + +# ── Layout fallback edge cases ────────────────────────────────────────────── +# #region test_detail_position_json_dict [C:2] [TYPE Function] +# @BRIEF Fallback extracts chart IDs when position_json is a dict. +@pytest.mark.asyncio +async def test_detail_position_json_dict(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 58, "dashboard_title": "D", "position_json": {"CHART-3": {"chartId": 3}}, "json_metadata": ""}}) + c.get_chart = AsyncMock(return_value={"result": {"id": 3, "slice_name": "C3", "viz_type": "t", "datasource_id": None, "changed_on": "t", "description": "d"}}) + c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []}) + assert any(ch["id"] == 3 for ch in (await c.get_dashboard_detail(58))["charts"]) +# #endregion test_detail_position_json_dict + +# #region test_detail_json_metadata_str [C:2] [TYPE Function] +# @BRIEF Fallback extracts chart IDs from json_metadata string. +@pytest.mark.asyncio +async def test_detail_json_metadata_str(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 59, "dashboard_title": "D", "position_json": "", "json_metadata": '{"charts": [{"chartId": 8}]}'}}) + c.get_chart = AsyncMock(return_value={"result": {"id": 8, "slice_name": "C8", "viz_type": "b", "datasource_id": None, "changed_on": "t", "description": "d"}}) + c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []}) + assert any(ch["id"] == 8 for ch in (await c.get_dashboard_detail(59))["charts"]) +# #endregion test_detail_json_metadata_str + +# #region test_detail_json_metadata_dict [C:2] [TYPE Function] +# @BRIEF json_metadata as dict is traversed for chart IDs. +@pytest.mark.asyncio +async def test_detail_json_metadata_dict(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 62, "dashboard_title": "D", "position_json": "", "json_metadata": {"charts": [{"chartId": 11}]}}}) + c.get_chart = AsyncMock(return_value={"result": {"id": 11, "slice_name": "C11", "viz_type": "x", "datasource_id": None, "changed_on": "t", "description": "d"}}) + c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []}) + assert any(ch["id"] == 11 for ch in (await c.get_dashboard_detail(62))["charts"]) +# #endregion test_detail_json_metadata_dict + +# #region test_detail_position_json_bad_string [C:2] [TYPE Function] +# @BRIEF Invalid position_json string handled gracefully. +@pytest.mark.asyncio +async def test_detail_position_json_bad_string(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 61, "dashboard_title": "D", "position_json": "{bad!!", "json_metadata": ""}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []}) + assert (await c.get_dashboard_detail(61))["charts"] == [] +# #endregion test_detail_position_json_bad_string + +# #region test_detail_json_metadata_bad_string [C:2] [TYPE Function] +# @BRIEF Invalid json_metadata string handled gracefully. +@pytest.mark.asyncio +async def test_detail_json_metadata_bad_string(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 63, "dashboard_title": "D", "position_json": "", "json_metadata": "not json{{{"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []}) + assert (await c.get_dashboard_detail(63))["charts"] == [] +# #endregion test_detail_json_metadata_bad_string + +# #region test_detail_fallback_chart_error [C:2] [TYPE Function] +# @BRIEF Fallback chart resolution continues on error. +@pytest.mark.asyncio +async def test_detail_fallback_chart_error(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 60, "dashboard_title": "D", "position_json": {"CHART-99": {"chartId": 99}}, "json_metadata": ""}}) + c.get_chart = AsyncMock(side_effect=RuntimeError("fail")); c.get_dataset = AsyncMock() + c.client.request = AsyncMock(return_value={"result": []}) + assert (await c.get_dashboard_detail(60))["charts"] == [] +# #endregion test_detail_fallback_chart_error + +# ── Type error edge cases ─────────────────────────────────────────────────── +# #region test_detail_dict_datasource_type_error [C:2] [TYPE Function] +# @BRIEF Dict datasource with non-convertible id → dataset_id=None. +@pytest.mark.asyncio +async def test_detail_dict_datasource_type_error(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 64, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": [{"id": 20, "slice_name": "C", "form_data": {"datasource": {"id": [1, 2]}}, "datasource_id": None}]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(64))["charts"][0]["dataset_id"] is None +# #endregion test_detail_dict_datasource_type_error + +# #region test_detail_datasource_id_type_error [C:2] [TYPE Function] +# @BRIEF datasource_id that can't convert to int → dataset_id=None. +@pytest.mark.asyncio +async def test_detail_datasource_id_type_error(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 65, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": [{"id": 21, "slice_name": "C", "form_data": {"datasource_id": [1]}, "datasource_id": None}]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(65))["charts"][0]["dataset_id"] is None +# #endregion test_detail_datasource_id_type_error + +# #region test_detail_backfill_dataset_type_error [C:2] [TYPE Function] +# @BRIEF Non-int dataset_id from charts skipped in backfill. +@pytest.mark.asyncio +async def test_detail_backfill_dataset_type_error(): + c = _make_client() + c.get_dashboard = AsyncMock(return_value={"result": {"id": 66, "dashboard_title": "D"}}) + c.get_chart = AsyncMock(); c.get_dataset = AsyncMock() + _detail_mock(c, charts_resp={"result": [{"id": 22, "slice_name": "C", "form_data": {}, "datasource_id": "not_int"}]}, datasets_resp={"result": []}) + assert (await c.get_dashboard_detail(66))["datasets"] == [] +# #endregion test_detail_backfill_dataset_type_error + +# ── export_dashboard ──────────────────────────────────────────────────────── +# #region test_export_dashboard_success [C:2] [TYPE Function] +# @BRIEF export_dashboard returns (content, filename) for valid ZIP. +@pytest.mark.asyncio +async def test_export_dashboard_success(): + c = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.headers = {"Content-Type": "application/zip", "Content-Disposition": 'attachment; filename="d.zip"'} + resp.content = b"PK\x03\x04data" + c.client.request = AsyncMock(return_value=resp) + content, fname = await c.export_dashboard(5) + assert content == b"PK\x03\x04data" and fname == "d.zip" +# #endregion test_export_dashboard_success + +# #region test_export_dashboard_invalid [C:2] [TYPE Function] +# @BRIEF export_dashboard raises SupersetAPIError for non-ZIP. +@pytest.mark.asyncio +async def test_export_dashboard_invalid(): + c = _make_client() + resp = MagicMock(spec=httpx.Response) + resp.headers = {"Content-Type": "text/html"}; resp.content = b"" + c.client.request = AsyncMock(return_value=resp) + with pytest.raises(SupersetAPIError): + await c.export_dashboard(5) +# #endregion test_export_dashboard_invalid + +# ── import_dashboard ──────────────────────────────────────────────────────── +# #region test_import_dashboard_success [C:2] [TYPE Function] +# @BRIEF import_dashboard validates file and delegates to _do_import. +@pytest.mark.asyncio +async def test_import_dashboard_success(tmp_path): + c = _make_client() + zp = _zip_with_metadata(tmp_path) + c.client.upload_file = AsyncMock(return_value={"status": "ok"}) + assert await c.import_dashboard(str(zp)) == {"status": "ok"} +# #endregion test_import_dashboard_success + +# #region test_import_dashboard_none [C:2] [TYPE Function] +# @BRIEF import_dashboard raises ValueError when file_name is None. +@pytest.mark.asyncio +async def test_import_dashboard_none(): + c = _make_client() + with pytest.raises(ValueError, match="file_name cannot be None"): + await c.import_dashboard(None) +# #endregion test_import_dashboard_none + +# #region test_import_retry_with_delete [C:2] [TYPE Function] +# @BRIEF import_dashboard deletes and retries when delete_before_reimport=True. +@pytest.mark.asyncio +async def test_import_retry_with_delete(tmp_path): + c = _make_client(); c.delete_before_reimport = True + zp = _zip_with_metadata(tmp_path, "retry.zip") + c.client.upload_file = AsyncMock(side_effect=[RuntimeError("conflict"), {"status": "ok"}]) + c.delete_dashboard = AsyncMock(); c.get_dashboards = AsyncMock(return_value=(1, [{"id": 55}])) + assert await c.import_dashboard(str(zp), dash_slug="s") == {"status": "ok"} + c.delete_dashboard.assert_awaited_once_with(55) +# #endregion test_import_retry_with_delete + +# #region test_import_no_retry [C:2] [TYPE Function] +# @BRIEF import_dashboard re-raises when delete_before_reimport=False. +@pytest.mark.asyncio +async def test_import_no_retry(tmp_path): + c = _make_client(); c.delete_before_reimport = False + zp = _zip_with_metadata(tmp_path, "nr.zip") + c.client.upload_file = AsyncMock(side_effect=RuntimeError("fail")) + with pytest.raises(RuntimeError, match="fail"): + await c.import_dashboard(str(zp)) +# #endregion test_import_no_retry + +# #region test_import_retry_no_target [C:2] [TYPE Function] +# @BRIEF import_dashboard re-raises when retry enabled but no target ID found. +@pytest.mark.asyncio +async def test_import_retry_no_target(tmp_path): + c = _make_client(); c.delete_before_reimport = True + zp = _zip_with_metadata(tmp_path, "nt.zip") + c.client.upload_file = AsyncMock(side_effect=RuntimeError("conflict")) + c.get_dashboards = AsyncMock(return_value=(0, [])) + with pytest.raises(RuntimeError, match="conflict"): + await c.import_dashboard(str(zp), dash_slug="missing") +# #endregion test_import_retry_no_target + +# ── delete_dashboard ──────────────────────────────────────────────────────── +# #region test_delete_dashboard_success [C:2] [TYPE Function] +# @BRIEF delete_dashboard sends DELETE for given ID. +@pytest.mark.asyncio +async def test_delete_dashboard_success(): + c = _make_client() + c.client.request = AsyncMock(return_value={"result": True}) + await c.delete_dashboard(42) + c.client.request.assert_awaited_once_with(method="DELETE", endpoint="/dashboard/42") +# #endregion test_delete_dashboard_success + +# #region test_delete_dashboard_unexpected [C:2] [TYPE Function] +# @BRIEF delete_dashboard handles result=False without raising. +@pytest.mark.asyncio +async def test_delete_dashboard_unexpected(): + c = _make_client() + c.client.request = AsyncMock(return_value={"result": False}) + await c.delete_dashboard(99) # no exception +# #endregion test_delete_dashboard_unexpected + +# #endregion Test.SupersetClient.DashboardsCrud diff --git a/backend/tests/core/superset_client/test_client_dashboards_list.py b/backend/tests/core/superset_client/test_client_dashboards_list.py new file mode 100644 index 00000000..62442b30 --- /dev/null +++ b/backend/tests/core/superset_client/test_client_dashboards_list.py @@ -0,0 +1,156 @@ +# #region Test.SupersetClient.DashboardsList [C:3] [TYPE Module] [SEMANTICS test,superset,dashboard,list] +# @BRIEF Unit tests for dashboard listing mixin — paginated list, summary projection. +# @RELATION BINDS_TO -> [SupersetDashboardsListMixin] +# @TEST_EDGE: empty_dashboards -> get_dashboards returns (0, []) +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.core.config_models import Environment +from src.core.superset_client import SupersetClient + + +def _make_client() -> SupersetClient: + env = Environment(id="env1", name="T", url="http://test.local", username="u", password="p") + client = SupersetClient(env) + mc = MagicMock() + mc.request = AsyncMock(return_value={}) + mc.fetch_paginated_data = AsyncMock(return_value=[]) + client.client = mc + client.network = mc + return client + + +# ── get_dashboards ────────────────────────────────────────────────────────── + +# #region test_get_dashboards_success [C:2] [TYPE Function] +# @BRIEF get_dashboards returns (count, data) from paginated fetch. +@pytest.mark.asyncio +async def test_get_dashboards_success(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[{"id": 1}, {"id": 2}, {"id": 3}]) + count, data = await client.get_dashboards() + assert count == 3 + assert len(data) == 3 +# #endregion test_get_dashboards_success + +# #region test_get_dashboards_empty [C:2] [TYPE Function] +# @BRIEF get_dashboards returns (0, []) when no dashboards exist. +@pytest.mark.asyncio +async def test_get_dashboards_empty(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[]) + count, data = await client.get_dashboards() + assert count == 0 + assert data == [] +# #endregion test_get_dashboards_empty + +# #region test_get_dashboards_with_query [C:2] [TYPE Function] +# @BRIEF get_dashboards passes custom query columns through. +@pytest.mark.asyncio +async def test_get_dashboards_with_query(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[{"id": 1}]) + await client.get_dashboards(query={"columns": ["id", "slug"]}) + call_kwargs = client.client.fetch_paginated_data.call_args + base_query = call_kwargs.kwargs["pagination_options"]["base_query"] + assert base_query["columns"] == ["id", "slug"] +# #endregion test_get_dashboards_with_query + +# ── get_dashboards_page ───────────────────────────────────────────────────── + +# #region test_get_dashboards_page [C:2] [TYPE Function] +# @BRIEF get_dashboards_page returns (count, result) from single API call. +@pytest.mark.asyncio +async def test_get_dashboards_page(): + client = _make_client() + client.client.request = AsyncMock(return_value={"result": [{"id": 1}], "count": 50}) + total, data = await client.get_dashboards_page() + assert total == 50 + assert len(data) == 1 +# #endregion test_get_dashboards_page + +# #region test_get_dashboards_page_no_count [C:2] [TYPE Function] +# @BRIEF get_dashboards_page falls back to len(result) when count is absent. +@pytest.mark.asyncio +async def test_get_dashboards_page_no_count(): + client = _make_client() + client.client.request = AsyncMock(return_value={"result": [{"id": 1}, {"id": 2}]}) + total, data = await client.get_dashboards_page() + assert total == 2 +# #endregion test_get_dashboards_page_no_count + +# ── get_dashboards_summary ────────────────────────────────────────────────── + +# #region test_get_dashboards_summary [C:2] [TYPE Function] +# @BRIEF get_dashboards_summary projects owner/creator fields correctly. +@pytest.mark.asyncio +async def test_get_dashboards_summary(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[{ + "id": 1, "slug": "s1", "dashboard_title": "T1", "url": "/u", + "changed_on_utc": "2025-01-01", "published": True, + "owners": [{"username": "alice", "first_name": "Alice", "last_name": "A"}], + "created_by": {"username": "bob"}, "changed_by": {"username": "carol"}, + "changed_by_name": "Carol", + }]) + result = await client.get_dashboards_summary() + assert len(result) == 1 + assert result[0]["status"] == "published" + assert result[0]["owners"] == ["Alice A"] +# #endregion test_get_dashboards_summary + +# #region test_get_dashboards_summary_draft [C:2] [TYPE Function] +# @BRIEF get_dashboards_summary marks unpublished dashboards as 'draft'. +@pytest.mark.asyncio +async def test_get_dashboards_summary_draft(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[{ + "id": 2, "slug": "s2", "dashboard_title": "Draft", "published": False, + "owners": None, "created_by": None, "changed_by": None, "changed_by_name": None, + }]) + result = await client.get_dashboards_summary() + assert result[0]["status"] == "draft" +# #endregion test_get_dashboards_summary_draft + +# #region test_get_dashboards_summary_require_slug [C:2] [TYPE Function] +# @BRIEF get_dashboards_summary adds slug filter when require_slug=True. +@pytest.mark.asyncio +async def test_get_dashboards_summary_require_slug(): + client = _make_client() + client.client.fetch_paginated_data = AsyncMock(return_value=[]) + await client.get_dashboards_summary(require_slug=True) + call_kwargs = client.client.fetch_paginated_data.call_args + base_query = call_kwargs.kwargs["pagination_options"]["base_query"] + assert any(f["col"] == "slug" for f in base_query.get("filters", [])) +# #endregion test_get_dashboards_summary_require_slug + +# ── get_dashboards_summary_page ───────────────────────────────────────────── + +# #region test_get_dashboards_summary_page [C:2] [TYPE Function] +# @BRIEF get_dashboards_summary_page returns projected page with search filter. +@pytest.mark.asyncio +async def test_get_dashboards_summary_page(): + client = _make_client() + client.client.request = AsyncMock(return_value={ + "result": [{"id": 1, "slug": "s", "dashboard_title": "Sales", "published": True, + "owners": [{"username": "admin"}], "created_by": None, "changed_by": None, "changed_by_name": None}], + "count": 1, + }) + total, data = await client.get_dashboards_summary_page(page=1, page_size=20, search="Sales") + assert total == 1 + assert data[0]["title"] == "Sales" +# #endregion test_get_dashboards_summary_page + +# #region test_get_dashboards_summary_page_no_search [C:2] [TYPE Function] +# @BRIEF get_dashboards_summary_page works without search term. +@pytest.mark.asyncio +async def test_get_dashboards_summary_page_no_search(): + client = _make_client() + client.client.request = AsyncMock(return_value={"result": [], "count": 0}) + total, data = await client.get_dashboards_summary_page(page=1, page_size=10) + assert total == 0 + assert data == [] +# #endregion test_get_dashboards_summary_page_no_search + +# #endregion Test.SupersetClient.DashboardsList diff --git a/backend/tests/core/test_core_config_manager.py b/backend/tests/core/test_core_config_manager.py new file mode 100644 index 00000000..bf139560 --- /dev/null +++ b/backend/tests/core/test_core_config_manager.py @@ -0,0 +1,858 @@ +# #region Test.Core.ConfigManager [C:3] [TYPE Module] [SEMANTICS test,config,manager] +# @BRIEF Unit tests for ConfigManager: init, load, save, environment CRUD, encryption, legacy migration. +# @RELATION BINDS_TO -> [ConfigManager] +# @TEST_EDGE: invalid_config_path -> ValueError on empty/non-string path +# @TEST_EDGE: missing_field -> legacy file absent falls back to default +# @TEST_EDGE: invalid_type -> save_config rejects non-AppConfig/non-dict +# @TEST_EDGE: external_fail -> DB save failure triggers rollback +from pathlib import Path +import sys +from unittest.mock import MagicMock, mock_open, patch + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import pytest + +from src.core.config_models import AppConfig, Environment, FeaturesConfig, GlobalSettings + +# Hardcoded fixture — frozen Fernet key for deterministic encryption tests. +_VALID_FERNET_KEY = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" + +_MODULE = "src.core.config_manager" + + +def _make_env(env_id="env1", name="Env1", password="secret", is_default=False): + return Environment(id=env_id, name=name, url="http://x", username="u", password=password, is_default=is_default) + + +def _make_config(envs=None, settings=None): + return AppConfig(environments=envs or [], settings=settings or GlobalSettings()) + + +def _build_manager(mock_session_local=None, config_path="config.json"): + """Build a ConfigManager bypassing __init__ side effects. Patches NOT active after return.""" + from src.core.config_manager import ConfigManager + mgr = object.__new__(ConfigManager) + mgr.config_path = Path(config_path) + mgr.raw_payload = {} + mgr.config = _make_config() + return mgr + + +# ── __init__ ──────────────────────────────────────────────────────────────── + +class TestInit: + # #region test_init_invalid_path_empty [C:2] [TYPE Function] + # @BRIEF Empty string config_path raises ValueError. + def test_init_invalid_path_empty(self): + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.logger") as m_log: + from src.core.config_manager import ConfigManager + with pytest.raises(ValueError, match="non-empty string"): + ConfigManager(config_path="") + # #endregion test_init_invalid_path_empty + + # #region test_init_invalid_path_type [C:2] [TYPE Function] + # @BRIEF Non-string config_path raises ValueError. + def test_init_invalid_path_type(self): + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.logger"): + from src.core.config_manager import ConfigManager + with pytest.raises(ValueError, match="non-empty string"): + ConfigManager(config_path=123) + # #endregion test_init_invalid_path_type + + +# ── _get_encryption_manager ───────────────────────────────────────────────── + +class TestGetEncryptionManager: + # #region test_encryption_manager_init_failure [C:2] [TYPE Function] + # @BRIEF EncryptionManager RuntimeError yields None with cached attribute. + def test_encryption_manager_init_failure(self): + mgr = _build_manager(MagicMock()) + with patch(f"{_MODULE}.EncryptionManager", side_effect=RuntimeError("no key")): + result = mgr._get_encryption_manager() + assert result is None + assert mgr._encryption is None + # #endregion test_encryption_manager_init_failure + + # #region test_encryption_manager_success [C:2] [TYPE Function] + # @BRIEF EncryptionManager init succeeds and is cached. + def test_encryption_manager_success(self): + mgr = _build_manager(MagicMock()) + fake_em = MagicMock() + with patch(f"{_MODULE}.EncryptionManager", return_value=fake_em): + result = mgr._get_encryption_manager() + assert result is fake_em + # Second call returns cached + assert mgr._get_encryption_manager() is fake_em + # #endregion test_encryption_manager_success + + +# ── _encrypt_env_passwords ────────────────────────────────────────────────── + +class TestEncryptEnvPasswords: + # #region test_encrypt_no_encryption [C:2] [TYPE Function] + # @BRIEF No encryption manager → passwords unchanged. + def test_encrypt_no_encryption(self): + mgr = _build_manager(MagicMock()) + mgr._encryption = None + payload = {"environments": [{"password": "secret"}]} + mgr._encrypt_env_passwords(payload) + assert payload["environments"][0]["password"] == "secret" + # #endregion test_encrypt_no_encryption + + # #region test_encrypt_non_list_environments [C:2] [TYPE Function] + # @BRIEF Non-list environments key → no-op. + def test_encrypt_non_list_environments(self): + mgr = _build_manager(MagicMock()) + mgr._encryption = MagicMock() + payload = {"environments": "not-a-list"} + mgr._encrypt_env_passwords(payload) # should not raise + # #endregion test_encrypt_non_list_environments + + # #region test_encrypt_skips_masked_and_empty [C:2] [TYPE Function] + # @BRIEF Masked (********) and empty passwords are skipped. + def test_encrypt_skips_masked_and_empty(self): + mgr = _build_manager(MagicMock()) + em = MagicMock() + mgr._encryption = em + payload = {"environments": [{"password": ""}, {"password": "********"}]} + mgr._encrypt_env_passwords(payload) + em.encrypt.assert_not_called() + # #endregion test_encrypt_skips_masked_and_empty + + # #region test_encrypt_skips_already_encrypted [C:2] [TYPE Function] + # @BRIEF Password that decrypts successfully is already encrypted → skip. + def test_encrypt_skips_already_encrypted(self): + mgr = _build_manager(MagicMock()) + em = MagicMock() + em.decrypt.return_value = "plaintext" + mgr._encryption = em + payload = {"environments": [{"password": "already-encrypted-token"}]} + mgr._encrypt_env_passwords(payload) + em.encrypt.assert_not_called() + # #endregion test_encrypt_skips_already_encrypted + + # #region test_encrypt_success [C:2] [TYPE Function] + # @BRIEF Plain password is encrypted in-place. + def test_encrypt_success(self): + mgr = _build_manager(MagicMock()) + em = MagicMock() + em.decrypt.side_effect = Exception("not encrypted") + em.encrypt.return_value = "encrypted-token" + mgr._encryption = em + payload = {"environments": [{"password": "plain"}]} + mgr._encrypt_env_passwords(payload) + assert payload["environments"][0]["password"] == "encrypted-token" + # #endregion test_encrypt_success + + # #region test_encrypt_failure_logs [C:2] [TYPE Function] + # @BRIEF Encryption failure logs and leaves password unchanged. + def test_encrypt_failure_logs(self): + mgr = _build_manager(MagicMock()) + em = MagicMock() + em.decrypt.side_effect = Exception("not encrypted") + em.encrypt.side_effect = RuntimeError("boom") + mgr._encryption = em + payload = {"environments": [{"password": "plain"}]} + with patch(f"{_MODULE}.logger"): + mgr._encrypt_env_passwords(payload) + assert payload["environments"][0]["password"] == "plain" + # #endregion test_encrypt_failure_logs + + +# ── _decrypt_env_passwords ────────────────────────────────────────────────── + +class TestDecryptEnvPasswords: + # #region test_decrypt_no_encryption [C:2] [TYPE Function] + # @BRIEF No encryption manager → passwords unchanged. + def test_decrypt_no_encryption(self): + mgr = _build_manager(MagicMock()) + mgr._encryption = None + config = _make_config([_make_env(password="secret")]) + mgr._decrypt_env_passwords(config) + assert config.environments[0].password == "secret" + # #endregion test_decrypt_no_encryption + + # #region test_decrypt_empty_password [C:2] [TYPE Function] + # @BRIEF Empty password is skipped. + def test_decrypt_empty_password(self): + mgr = _build_manager(MagicMock()) + mgr._encryption = MagicMock() + config = _make_config([_make_env(password="")]) + mgr._decrypt_env_passwords(config) + mgr._encryption.decrypt.assert_not_called() + # #endregion test_decrypt_empty_password + + # #region test_decrypt_success [C:2] [TYPE Function] + # @BRIEF Encrypted password is decrypted in-place. + def test_decrypt_success(self): + mgr = _build_manager(MagicMock()) + em = MagicMock() + em.decrypt.return_value = "decrypted" + mgr._encryption = em + config = _make_config([_make_env(password="encrypted-token")]) + mgr._decrypt_env_passwords(config) + assert config.environments[0].password == "decrypted" + # #endregion test_decrypt_success + + # #region test_decrypt_failure_keeps_plaintext [C:2] [TYPE Function] + # @BRIEF Decrypt failure assumes plaintext legacy data → password unchanged. + def test_decrypt_failure_keeps_plaintext(self): + mgr = _build_manager(MagicMock()) + em = MagicMock() + em.decrypt.side_effect = Exception("bad data") + mgr._encryption = em + config = _make_config([_make_env(password="legacy-plain")]) + mgr._decrypt_env_passwords(config) + assert config.environments[0].password == "legacy-plain" + # #endregion test_decrypt_failure_keeps_plaintext + + +# ── _apply_features_from_env ──────────────────────────────────────────────── + +class TestApplyFeaturesFromEnv: + # #region test_apply_timezone_from_env [C:2] [TYPE Function] + # @BRIEF APP_TIMEZONE env var overrides settings.app_timezone. + def test_apply_timezone_from_env(self): + from src.core.config_manager import ConfigManager + settings = GlobalSettings() + with patch.dict("os.environ", {"APP_TIMEZONE": "US/Eastern"}), \ + patch(f"{_MODULE}.belief_scope"): + ConfigManager._apply_features_from_env(settings) + assert settings.app_timezone == "US/Eastern" + # #endregion test_apply_timezone_from_env + + # #region test_apply_feature_flags_from_env [C:2] [TYPE Function] + # @BRIEF FEATURES__* env vars override feature flags. + def test_apply_feature_flags_from_env(self): + from src.core.config_manager import ConfigManager + settings = GlobalSettings() + with patch.dict("os.environ", {"FEATURES__TRANSLATE": "false"}), \ + patch(f"{_MODULE}.belief_scope"): + ConfigManager._apply_features_from_env(settings) + assert settings.features.translate is False + # #endregion test_apply_feature_flags_from_env + + +# ── _load_from_legacy_file ────────────────────────────────────────────────── + +class TestLoadFromLegacyFile: + # #region test_legacy_file_not_found [C:2] [TYPE Function] + # @BRIEF Missing legacy file returns empty dict. + def test_legacy_file_not_found(self): + mgr = _build_manager(MagicMock(), config_path="/nonexistent/config.json") + with patch(f"{_MODULE}.belief_scope"): + result = mgr._load_from_legacy_file() + assert result == {} + # #endregion test_legacy_file_not_found + + # #region test_legacy_file_valid_json [C:2] [TYPE Function] + # @BRIEF Valid JSON object loaded from legacy file. + def test_legacy_file_valid_json(self, tmp_path): + cfg = tmp_path / "config.json" + cfg.write_text('{"environments": [], "settings": {}}') + mgr = _build_manager(MagicMock(), config_path=str(cfg)) + with patch(f"{_MODULE}.belief_scope"): + result = mgr._load_from_legacy_file() + assert result == {"environments": [], "settings": {}} + # #endregion test_legacy_file_valid_json + + # #region test_legacy_file_non_dict [C:2] [TYPE Function] + # @BRIEF Non-dict JSON payload raises ValueError. + def test_legacy_file_non_dict(self, tmp_path): + cfg = tmp_path / "config.json" + cfg.write_text('"just a string"') + mgr = _build_manager(MagicMock(), config_path=str(cfg)) + with patch(f"{_MODULE}.belief_scope"): + with pytest.raises(ValueError, match="JSON object"): + mgr._load_from_legacy_file() + # #endregion test_legacy_file_non_dict + + +# ── _load_config ──────────────────────────────────────────────────────────── + +class TestLoadConfig: + # #region test_load_from_db [C:2] [TYPE Function] + # @BRIEF DB record present → config loaded from DB payload. + def test_load_from_db(self): + mock_session = MagicMock() + record = MagicMock() + record.id = "global" + record.payload = {"environments": [], "settings": {}} + mock_session.query.return_value.filter.return_value.first.return_value = record + mock_sl = MagicMock(return_value=mock_session) + + mgr = _build_manager() + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl), \ + patch.object(mgr, "_decrypt_env_passwords"), \ + patch.object(mgr, "_sync_environment_records"): + config = mgr._load_config() + assert isinstance(config, AppConfig) + mock_session.commit.assert_called_once() + # #endregion test_load_from_db + + # #region test_load_from_legacy_migration [C:2] [TYPE Function] + # @BRIEF No DB record + legacy file present → migration path. + def test_load_from_legacy_migration(self, tmp_path): + cfg = tmp_path / "config.json" + cfg.write_text('{"environments": [], "settings": {}}') + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.first.return_value = None + mock_sl = MagicMock(return_value=mock_session) + + mgr = _build_manager(config_path=str(cfg)) + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl), \ + patch.object(mgr, "_decrypt_env_passwords"), \ + patch.object(mgr, "_apply_features_from_env"), patch.object(mgr, "_save_config_to_db"): + config = mgr._load_config() + assert isinstance(config, AppConfig) + # #endregion test_load_from_legacy_migration + + # #region test_load_default_fallback [C:2] [TYPE Function] + # @BRIEF No DB record + no legacy file → default config. + def test_load_default_fallback(self): + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.first.return_value = None + mock_sl = MagicMock(return_value=mock_session) + + mgr = _build_manager(config_path="/nonexistent/config.json") + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl), \ + patch.object(mgr, "_save_config_to_db"): + config = mgr._load_config() + assert isinstance(config, AppConfig) + assert config.environments == [] + # #endregion test_load_default_fallback + + # #region test_load_recoverable_error [C:2] [TYPE Function] + # @BRIEF JSON/Type/Value error during load → default config fallback. + def test_load_recoverable_error(self): + mock_session = MagicMock() + mock_session.query.side_effect = ValueError("bad data") + mock_sl = MagicMock(return_value=mock_session) + + mgr = _build_manager() + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl): + config = mgr._load_config() + assert isinstance(config, AppConfig) + # #endregion test_load_recoverable_error + + # #region test_load_critical_error_reraises [C:2] [TYPE Function] + # @BRIEF Non-recoverable exception during load is re-raised. + def test_load_critical_error_reraises(self): + mock_session = MagicMock() + mock_session.query.side_effect = RuntimeError("critical") + mock_sl = MagicMock(return_value=mock_session) + + mgr = _build_manager() + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl): + with pytest.raises(RuntimeError, match="critical"): + mgr._load_config() + # #endregion test_load_critical_error_reraises + + +# ── _sync_environment_records ─────────────────────────────────────────────── + +class TestSyncEnvironmentRecords: + # #region test_sync_creates_new_records [C:2] [TYPE Function] + # @BRIEF New environments are added as DB records. + def test_sync_creates_new_records(self): + mock_session = MagicMock() + mock_session.query.return_value.all.return_value = [] + mgr = _build_manager(MagicMock()) + config = _make_config([_make_env("env1", "Env One")]) + with patch(f"{_MODULE}.belief_scope"): + mgr._sync_environment_records(mock_session, config) + mock_session.add.assert_called_once() + # #endregion test_sync_creates_new_records + + # #region test_sync_updates_existing_records [C:2] [TYPE Function] + # @BRIEF Existing environment records are updated in-place. + def test_sync_updates_existing_records(self): + existing = MagicMock() + existing.id = "env1" + mock_session = MagicMock() + mock_session.query.return_value.all.return_value = [existing] + mgr = _build_manager(MagicMock()) + config = _make_config([_make_env("env1", "Updated Name")]) + with patch(f"{_MODULE}.belief_scope"): + mgr._sync_environment_records(mock_session, config) + assert existing.name == "Updated Name" + mock_session.add.assert_not_called() + # #endregion test_sync_updates_existing_records + + # #region test_sync_skips_empty_id [C:2] [TYPE Function] + # @BRIEF Environments with empty id are skipped. + def test_sync_skips_empty_id(self): + mock_session = MagicMock() + mock_session.query.return_value.all.return_value = [] + mgr = _build_manager(MagicMock()) + config = _make_config([_make_env("", "NoID")]) + with patch(f"{_MODULE}.belief_scope"): + mgr._sync_environment_records(mock_session, config) + mock_session.add.assert_not_called() + # #endregion test_sync_skips_empty_id + + +# ── _delete_stale_environment_records ─────────────────────────────────────── + +class TestDeleteStaleEnvironmentRecords: + # #region test_delete_stale_records [C:2] [TYPE Function] + # @BRIEF Persisted records not in config are deleted. + def test_delete_stale_records(self): + stale = MagicMock() + stale.id = "stale-env" + mock_session = MagicMock() + mock_session.query.return_value.all.return_value = [stale] + mgr = _build_manager(MagicMock()) + config = _make_config([_make_env("env1")]) + with patch(f"{_MODULE}.belief_scope"): + mgr._delete_stale_environment_records(mock_session, config) + mock_session.delete.assert_called_once_with(stale) + # #endregion test_delete_stale_records + + # #region test_delete_no_stale [C:2] [TYPE Function] + # @BRIEF All persisted records match config → nothing deleted. + def test_delete_no_stale(self): + rec = MagicMock() + rec.id = "env1" + mock_session = MagicMock() + mock_session.query.return_value.all.return_value = [rec] + mgr = _build_manager(MagicMock()) + config = _make_config([_make_env("env1")]) + with patch(f"{_MODULE}.belief_scope"): + mgr._delete_stale_environment_records(mock_session, config) + mock_session.delete.assert_not_called() + # #endregion test_delete_no_stale + + +# ── _save_config_to_db ────────────────────────────────────────────────────── + +class TestSaveConfigToDb: + # #region test_save_creates_new_record [C:2] [TYPE Function] + # @BRIEF No existing record → new AppConfigRecord created. + def test_save_creates_new_record(self): + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.first.return_value = None + mgr = _build_manager(MagicMock()) + config = _make_config() + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_encrypt_env_passwords"), \ + patch.object(mgr, "_decrypt_env_passwords"), patch.object(mgr, "_sync_environment_records"), \ + patch.object(mgr, "_delete_stale_environment_records"): + mgr._save_config_to_db(config, session=mock_session) + mock_session.add.assert_called_once() + mock_session.commit.assert_called_once() + # #endregion test_save_creates_new_record + + # #region test_save_updates_existing_record [C:2] [TYPE Function] + # @BRIEF Existing record → payload updated in-place. + def test_save_updates_existing_record(self): + record = MagicMock() + record.id = "global" + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.first.return_value = record + mgr = _build_manager(MagicMock()) + config = _make_config() + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_encrypt_env_passwords"), \ + patch.object(mgr, "_decrypt_env_passwords"), patch.object(mgr, "_sync_environment_records"), \ + patch.object(mgr, "_delete_stale_environment_records"): + mgr._save_config_to_db(config, session=mock_session) + assert record.payload is not None + mock_session.add.assert_not_called() + # #endregion test_save_updates_existing_record + + # #region test_save_rollback_on_error [C:2] [TYPE Function] + # @BRIEF DB commit failure → rollback and re-raise. + def test_save_rollback_on_error(self): + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.first.return_value = None + mock_session.commit.side_effect = RuntimeError("db error") + mgr = _build_manager(MagicMock()) + config = _make_config() + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_encrypt_env_passwords"), \ + patch.object(mgr, "_sync_environment_records"), \ + patch.object(mgr, "_delete_stale_environment_records"): + with pytest.raises(RuntimeError, match="db error"): + mgr._save_config_to_db(config, session=mock_session) + mock_session.rollback.assert_called_once() + # #endregion test_save_rollback_on_error + + # #region test_save_owns_session_closes [C:2] [TYPE Function] + # @BRIEF When no session provided, manager creates and closes its own. + def test_save_owns_session_closes(self): + mock_session = MagicMock() + mock_session.query.return_value.filter.return_value.first.return_value = None + mock_sl = MagicMock(return_value=mock_session) + mgr = _build_manager() + config = _make_config() + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl), \ + patch.object(mgr, "_encrypt_env_passwords"), \ + patch.object(mgr, "_decrypt_env_passwords"), patch.object(mgr, "_sync_environment_records"), \ + patch.object(mgr, "_delete_stale_environment_records"): + mgr._save_config_to_db(config, session=None) + mock_session.close.assert_called_once() + # #endregion test_save_owns_session_closes + + +# ── save / get_config / get_payload ───────────────────────────────────────── + +class TestPublicAccessors: + # #region test_save_delegates [C:2] [TYPE Function] + # @BRIEF save() delegates to _save_config_to_db with current config. + def test_save_delegates(self): + mgr = _build_manager(MagicMock()) + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_save_config_to_db") as m: + mgr.save() + m.assert_called_once_with(mgr.config) + # #endregion test_save_delegates + + # #region test_get_config [C:2] [TYPE Function] + # @BRIEF get_config returns current in-memory config. + def test_get_config(self): + mgr = _build_manager(MagicMock()) + assert mgr.get_config() is mgr.config + # #endregion test_get_config + + # #region test_get_payload [C:2] [TYPE Function] + # @BRIEF get_payload syncs and returns raw payload. + def test_get_payload(self): + mgr = _build_manager(MagicMock()) + with patch(f"{_MODULE}.belief_scope"): + result = mgr.get_payload() + assert isinstance(result, dict) + assert "environments" in result + # #endregion test_get_payload + + +# ── save_config ───────────────────────────────────────────────────────────── + +class TestSaveConfig: + # #region test_save_config_typed [C:2] [TYPE Function] + # @BRIEF AppConfig input → saved and returned. + def test_save_config_typed(self): + mgr = _build_manager(MagicMock()) + config = _make_config() + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_save_config_to_db"): + result = mgr.save_config(config) + assert result is config + # #endregion test_save_config_typed + + # #region test_save_config_dict [C:2] [TYPE Function] + # @BRIEF Dict input → validated and saved. + def test_save_config_dict(self): + mgr = _build_manager(MagicMock()) + payload = {"environments": [], "settings": {}} + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_save_config_to_db"): + result = mgr.save_config(payload) + assert isinstance(result, AppConfig) + # #endregion test_save_config_dict + + # #region test_save_config_invalid_type [C:2] [TYPE Function] + # @BRIEF Non-AppConfig/non-dict → TypeError. + def test_save_config_invalid_type(self): + mgr = _build_manager(MagicMock()) + with patch(f"{_MODULE}.belief_scope"): + with pytest.raises(TypeError, match="AppConfig or dict"): + mgr.save_config("invalid") + # #endregion test_save_config_invalid_type + + +# ── update_global_settings ────────────────────────────────────────────────── + +class TestUpdateGlobalSettings: + # #region test_update_settings [C:2] [TYPE Function] + # @BRIEF Settings replaced and saved. + def test_update_settings(self): + mgr = _build_manager(MagicMock()) + new_settings = GlobalSettings(app_timezone="US/Pacific") + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + result = mgr.update_global_settings(new_settings) + assert result.settings.app_timezone == "US/Pacific" + # #endregion test_update_settings + + +# ── validate_path ─────────────────────────────────────────────────────────── + +class TestValidatePath: + # #region test_validate_path_success [C:2] [TYPE Function] + # @BRIEF Writable directory → (True, "OK"). + def test_validate_path_success(self, tmp_path): + mgr = _build_manager(MagicMock()) + with patch(f"{_MODULE}.belief_scope"): + ok, msg = mgr.validate_path(str(tmp_path)) + assert ok is True + assert msg == "OK" + # #endregion test_validate_path_success + + # #region test_validate_path_not_exist [C:2] [TYPE Function] + # @BRIEF Path does not exist after mkdir attempt → (False, ...). + def test_validate_path_not_exist(self): + mgr = _build_manager(MagicMock()) + fake_path = MagicMock() + fake_path.expanduser.return_value = fake_path + fake_path.exists.return_value = False + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.Path", return_value=fake_path): + ok, msg = mgr.validate_path("/nonexistent/path") + assert ok is False + assert "does not exist" in msg + # #endregion test_validate_path_not_exist + + # #region test_validate_path_not_dir [C:2] [TYPE Function] + # @BRIEF Path is a file, not a directory → (False, ...). + def test_validate_path_not_dir(self): + mgr = _build_manager(MagicMock()) + fake_path = MagicMock() + fake_path.expanduser.return_value = fake_path + fake_path.exists.return_value = True + fake_path.is_dir.return_value = False + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.Path", return_value=fake_path): + ok, msg = mgr.validate_path("/some/file") + assert ok is False + assert "not a directory" in msg + # #endregion test_validate_path_not_dir + + # #region test_validate_path_exception [C:2] [TYPE Function] + # @BRIEF Exception during validation → (False, error message). + def test_validate_path_exception(self): + mgr = _build_manager(MagicMock()) + fake_path = MagicMock() + fake_path.expanduser.side_effect = PermissionError("denied") + with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.Path", return_value=fake_path): + ok, msg = mgr.validate_path("/restricted/path") + assert ok is False + assert "denied" in msg + # #endregion test_validate_path_exception + + +# ── get_environments / has_environments / get_environment ──────────────────── + +class TestEnvironmentAccessors: + # #region test_get_environments [C:2] [TYPE Function] + # @BRIEF Returns list copy of environments. + def test_get_environments(self): + env = _make_env() + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([env]) + with patch(f"{_MODULE}.belief_scope"): + result = mgr.get_environments() + assert len(result) == 1 + assert result[0].id == "env1" + # #endregion test_get_environments + + # #region test_has_environments_true [C:2] [TYPE Function] + # @BRIEF Returns True when environments exist. + def test_has_environments_true(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env()]) + with patch(f"{_MODULE}.belief_scope"): + assert mgr.has_environments() is True + # #endregion test_has_environments_true + + # #region test_has_environments_false [C:2] [TYPE Function] + # @BRIEF Returns False when no environments. + def test_has_environments_false(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([]) + with patch(f"{_MODULE}.belief_scope"): + assert mgr.has_environments() is False + # #endregion test_has_environments_false + + # #region test_get_environment_by_id [C:2] [TYPE Function] + # @BRIEF Lookup by id returns matching environment. + def test_get_environment_by_id(self): + env = _make_env("env1", "Env One") + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([env]) + with patch(f"{_MODULE}.belief_scope"): + assert mgr.get_environment("env1") is env + # #endregion test_get_environment_by_id + + # #region test_get_environment_by_name [C:2] [TYPE Function] + # @BRIEF Lookup by name returns matching environment. + def test_get_environment_by_name(self): + env = _make_env("env1", "Env One") + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([env]) + with patch(f"{_MODULE}.belief_scope"): + assert mgr.get_environment("Env One") is env + # #endregion test_get_environment_by_name + + # #region test_get_environment_not_found [C:2] [TYPE Function] + # @BRIEF Unknown id → None. + def test_get_environment_not_found(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env()]) + with patch(f"{_MODULE}.belief_scope"): + assert mgr.get_environment("nonexistent") is None + # #endregion test_get_environment_not_found + + # #region test_get_environment_empty_id [C:2] [TYPE Function] + # @BRIEF Empty/None id → None. + def test_get_environment_empty_id(self): + mgr = _build_manager(MagicMock()) + with patch(f"{_MODULE}.belief_scope"): + assert mgr.get_environment("") is None + assert mgr.get_environment(None) is None + # #endregion test_get_environment_empty_id + + +# ── add_environment ───────────────────────────────────────────────────────── + +class TestAddEnvironment: + # #region test_add_new_environment [C:2] [TYPE Function] + # @BRIEF New environment appended and saved. + def test_add_new_environment(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([]) + env = _make_env("new1", "New Env") + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + result = mgr.add_environment(env) + assert len(result.environments) == 1 + assert result.environments[0].id == "new1" + # #endregion test_add_new_environment + + # #region test_add_replaces_existing [C:2] [TYPE Function] + # @BRIEF Existing id → replaced in-place. + def test_add_replaces_existing(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env("env1", "Old")]) + env = _make_env("env1", "New") + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + result = mgr.add_environment(env) + assert len(result.environments) == 1 + assert result.environments[0].name == "New" + # #endregion test_add_replaces_existing + + # #region test_add_default_clears_others [C:2] [TYPE Function] + # @BRIEF Adding a default env clears is_default on others. + def test_add_default_clears_others(self): + mgr = _build_manager(MagicMock()) + old = _make_env("env1", is_default=True) + mgr.config = _make_config([old]) + new = _make_env("env2", is_default=True) + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + mgr.add_environment(new) + assert mgr.config.environments[0].is_default is False + assert mgr.config.environments[1].is_default is True + # #endregion test_add_default_clears_others + + # #region test_add_first_env_becomes_default [C:2] [TYPE Function] + # @BRIEF First environment auto-becomes default if none marked. + def test_add_first_env_becomes_default(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([]) + env = _make_env("env1", is_default=False) + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + mgr.add_environment(env) + assert mgr.config.environments[0].is_default is True + # #endregion test_add_first_env_becomes_default + + +# ── update_environment ────────────────────────────────────────────────────── + +class TestUpdateEnvironment: + # #region test_update_existing [C:2] [TYPE Function] + # @BRIEF Existing env updated and saved. + def test_update_existing(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env("env1", "Old", password="real")]) + updated = _make_env("env1", "New", password="new-pwd") + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + result = mgr.update_environment("env1", updated) + assert result is True + assert mgr.config.environments[0].name == "New" + # #endregion test_update_existing + + # #region test_update_masked_password_preserved [C:2] [TYPE Function] + # @BRIEF Masked password (********) preserves existing password. + def test_update_masked_password_preserved(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env("env1", password="real-secret")]) + updated = _make_env("env1", password="********") + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + mgr.update_environment("env1", updated) + assert mgr.config.environments[0].password == "real-secret" + # #endregion test_update_masked_password_preserved + + # #region test_update_not_found [C:2] [TYPE Function] + # @BRIEF Unknown env_id → returns False. + def test_update_not_found(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env("env1")]) + with patch(f"{_MODULE}.belief_scope"): + result = mgr.update_environment("nonexistent", _make_env("env2")) + assert result is False + # #endregion test_update_not_found + + # #region test_update_default_preservation [C:2] [TYPE Function] + # @BRIEF Removing is_default from the only default keeps it default. + def test_update_default_preservation(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env("env1", is_default=True)]) + updated = _make_env("env1", is_default=False) + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + mgr.update_environment("env1", updated) + assert mgr.config.environments[0].is_default is True + # #endregion test_update_default_preservation + + # #region test_update_sets_default_clears_others [C:2] [TYPE Function] + # @BRIEF Setting is_default=True on update clears is_default on all others. + def test_update_sets_default_clears_others(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([ + _make_env("env1", is_default=True), + _make_env("env2", is_default=False), + ]) + updated = _make_env("env2", is_default=True) + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + mgr.update_environment("env2", updated) + assert mgr.config.environments[0].is_default is False + assert mgr.config.environments[1].is_default is True + # #endregion test_update_sets_default_clears_others + + +# ── delete_environment ────────────────────────────────────────────────────── + +class TestDeleteEnvironment: + # #region test_delete_existing [C:2] [TYPE Function] + # @BRIEF Existing env deleted and saved. + def test_delete_existing(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env("env1"), _make_env("env2")]) + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + result = mgr.delete_environment("env1") + assert result is True + assert len(mgr.config.environments) == 1 + assert mgr.config.environments[0].id == "env2" + # #endregion test_delete_existing + + # #region test_delete_not_found [C:2] [TYPE Function] + # @BRIEF Unknown env_id → returns False. + def test_delete_not_found(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env("env1")]) + with patch(f"{_MODULE}.belief_scope"): + result = mgr.delete_environment("nonexistent") + assert result is False + # #endregion test_delete_not_found + + # #region test_delete_default_reassigns [C:2] [TYPE Function] + # @BRIEF Deleting default env → first remaining becomes default. + def test_delete_default_reassigns(self): + mgr = _build_manager(MagicMock()) + mgr.config = _make_config([_make_env("env1", is_default=True), _make_env("env2")]) + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + mgr.delete_environment("env1") + assert mgr.config.environments[0].is_default is True + # #endregion test_delete_default_reassigns + + # #region test_delete_updates_default_setting [C:2] [TYPE Function] + # @BRIEF Deleting env referenced in settings.default_environment_id updates it. + def test_delete_updates_default_setting(self): + mgr = _build_manager(MagicMock()) + settings = GlobalSettings(default_environment_id="env1") + mgr.config = _make_config([_make_env("env1", is_default=True), _make_env("env2")], settings=settings) + with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"): + mgr.delete_environment("env1") + assert mgr.config.settings.default_environment_id != "env1" + # #endregion test_delete_updates_default_setting +#endregion Test.Core.ConfigManager diff --git a/backend/tests/core/test_core_database.py b/backend/tests/core/test_core_database.py new file mode 100644 index 00000000..006ba5d8 --- /dev/null +++ b/backend/tests/core/test_core_database.py @@ -0,0 +1,563 @@ +# #region Test.Core.Database [C:3] [TYPE Module] [SEMANTICS test,database,core] +# @BRIEF Verify DatabaseModule — engine creation, session lifecycle, additive migrations, init_db. +# @RELATION BINDS_TO -> [DatabaseModule] +# @TEST_EDGE: table_not_exists -> migration returns early +# @TEST_EDGE: columns_present -> no ALTER executed +# @TEST_EDGE: migration_failure -> exception handled per function contract +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch +import contextlib +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "src")) +from src.core.database import ( + BASE_DIR, _build_engine, init_db, get_db, get_tasks_db, get_auth_db, + _ensure_user_dashboard_preferences_columns, + _ensure_user_dashboard_preferences_health_columns, + _ensure_llm_validation_results_columns, + _ensure_git_server_configs_columns, + _ensure_auth_users_columns, + _ensure_roles_is_admin_column, + _ensure_filter_source_enum_values, + _ensure_translation_jobs_columns, + _ensure_dataset_review_session_columns, + _ensure_translation_schedules_columns, + _ensure_dictionary_entries_columns, +) + +class _FakeCM: + def __init__(self, conn): self._conn = conn + def __enter__(self): return self._conn + def __exit__(self, *a): return False + +_NOOP = contextlib.contextmanager(lambda id, msg="": (yield)) +_P = "src.core.database" + +def _insp(tables=None, cols=None): + m = MagicMock() + m.get_table_names.return_value = tables or [] + m.get_columns.return_value = [{"name": c} for c in (cols or [])] + return m + +def _eng(): + e, c = MagicMock(), MagicMock() + e.begin.return_value = _FakeCM(c) + e.connect.return_value = _FakeCM(c) + return e, c + +# ═══════════════════════════════════════════════════════════════════════════ +# Engine creation & constants +# ═══════════════════════════════════════════════════════════════════════════ + +# #region test_build_engine [C:2] [TYPE Function] +# @BRIEF SQLite gets check_same_thread; Postgres gets pool_pre_ping. +@patch(f"{_P}.create_engine") +def test_build_engine(mock_ce): + with patch(f"{_P}.belief_scope", _NOOP): + _build_engine("sqlite:///t.db") + _build_engine("postgresql://u:p@h/d") + assert mock_ce.call_args_list[0].kwargs == {"connect_args": {"check_same_thread": False}} + assert mock_ce.call_args_list[1].kwargs == {"pool_pre_ping": True} +# #endregion test_build_engine + +# #region test_base_dir [C:2] [TYPE Function] +# @BRIEF BASE_DIR resolves to backend root. +def test_base_dir(): + assert BASE_DIR == Path(__file__).resolve().parent.parent.parent +# #endregion test_base_dir + +# ═══════════════════════════════════════════════════════════════════════════ +# Parametrized migration tests +# ═══════════════════════════════════════════════════════════════════════════ + +# #region test_migration_table_missing [C:2] [TYPE Function] +# @BRIEF All migration functions return early when table absent. +@pytest.mark.parametrize("fn,table", [ + (_ensure_user_dashboard_preferences_columns, "user_dashboard_preferences"), + (_ensure_user_dashboard_preferences_health_columns, "user_dashboard_preferences"), + (_ensure_llm_validation_results_columns, "llm_validation_results"), + (_ensure_git_server_configs_columns, "git_server_configs"), + (_ensure_auth_users_columns, "users"), + (_ensure_roles_is_admin_column, "roles"), + (_ensure_translation_jobs_columns, "translation_jobs"), + (_ensure_translation_schedules_columns, "translation_schedules"), + (_ensure_dictionary_entries_columns, "dictionary_entries"), +]) +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_migration_table_missing(mock_inspect, fn, table): + mock_inspect.return_value = _insp(tables=[]) + eng = MagicMock() + fn(eng) + eng.begin.assert_not_called() +# #endregion test_migration_table_missing + +# #region test_pref_columns_add [C:2] [TYPE Function] +# @BRIEF Adds 6 missing columns to user_dashboard_preferences. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_pref_columns_add(mock_inspect, _): + mock_inspect.return_value = _insp(["user_dashboard_preferences"], ["git_username"]) + eng, conn = _eng() + _ensure_user_dashboard_preferences_columns(eng) + assert conn.execute.call_count == 6 +# #endregion test_pref_columns_add + +# #region test_pref_columns_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when all 7 pref columns exist. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_pref_columns_all_present(mock_inspect): + cols = ["git_username","git_email","git_personal_access_token_encrypted", + "start_page","auto_open_task_drawer","dashboards_table_density","show_only_slug_dashboards"] + mock_inspect.return_value = _insp(["user_dashboard_preferences"], cols) + eng = MagicMock() + _ensure_user_dashboard_preferences_columns(eng) + eng.begin.assert_not_called() +# #endregion test_pref_columns_all_present + +# #region test_pref_failure_swallowed [C:2] [TYPE Function] +# @BRIEF Exception during pref migration is logged, not re-raised. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("boom")) +@patch(f"{_P}.inspect") +def test_pref_failure_swallowed(mock_inspect, _): + mock_inspect.return_value = _insp(["user_dashboard_preferences"], []) + eng, _ = _eng() + _ensure_user_dashboard_preferences_columns(eng) # must not raise +# #endregion test_pref_failure_swallowed + +# #region test_health_columns [C:2] [TYPE Function] +# @BRIEF Health columns: add 3 when missing, skip when present, swallow errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_health_columns(mock_inspect, _): + mock_inspect.return_value = _insp(["user_dashboard_preferences"], []) + eng, conn = _eng() + _ensure_user_dashboard_preferences_health_columns(eng) + assert conn.execute.call_count == 3 +# #endregion test_health_columns + +# #region test_health_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when all health columns exist. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_health_all_present(mock_inspect): + mock_inspect.return_value = _insp(["user_dashboard_preferences"], + ["telegram_id","email_address","notify_on_fail"]) + eng = MagicMock() + _ensure_user_dashboard_preferences_health_columns(eng) + eng.begin.assert_not_called() +# #endregion test_health_all_present + +# #region test_health_failure_swallowed [C:2] [TYPE Function] +# @BRIEF Health migration exception is logged, not re-raised. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("boom")) +@patch(f"{_P}.inspect") +def test_health_failure_swallowed(mock_inspect, _): + mock_inspect.return_value = _insp(["user_dashboard_preferences"], []) + eng, _ = _eng() + _ensure_user_dashboard_preferences_health_columns(eng) +# #endregion test_health_failure_swallowed + +# #region test_llm_columns [C:2] [TYPE Function] +# @BRIEF LLM validation: add 2 columns, skip when present, swallow errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_llm_columns(mock_inspect, _): + mock_inspect.return_value = _insp(["llm_validation_results"], []) + eng, conn = _eng() + _ensure_llm_validation_results_columns(eng) + assert conn.execute.call_count == 2 +# #endregion test_llm_columns + +# #region test_llm_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when task_id and environment_id exist. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_llm_all_present(mock_inspect): + mock_inspect.return_value = _insp(["llm_validation_results"], ["task_id","environment_id"]) + eng = MagicMock() + _ensure_llm_validation_results_columns(eng) + eng.begin.assert_not_called() +# #endregion test_llm_all_present + +# #region test_llm_failure_swallowed [C:2] [TYPE Function] +# @BRIEF LLM migration exception is logged, not re-raised. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("boom")) +@patch(f"{_P}.inspect") +def test_llm_failure_swallowed(mock_inspect, _): + mock_inspect.return_value = _insp(["llm_validation_results"], []) + eng, _ = _eng() + _ensure_llm_validation_results_columns(eng) +# #endregion test_llm_failure_swallowed + +# #region test_git_columns [C:2] [TYPE Function] +# @BRIEF Git configs: add default_branch, skip when present, swallow errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_git_columns(mock_inspect, _): + mock_inspect.return_value = _insp(["git_server_configs"], []) + eng, conn = _eng() + _ensure_git_server_configs_columns(eng) + assert conn.execute.call_count == 1 +# #endregion test_git_columns + +# #region test_git_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when default_branch exists. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_git_all_present(mock_inspect): + mock_inspect.return_value = _insp(["git_server_configs"], ["default_branch"]) + eng = MagicMock() + _ensure_git_server_configs_columns(eng) + eng.begin.assert_not_called() +# #endregion test_git_all_present + +# #region test_git_failure_swallowed [C:2] [TYPE Function] +# @BRIEF Git migration exception is logged, not re-raised. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("boom")) +@patch(f"{_P}.inspect") +def test_git_failure_swallowed(mock_inspect, _): + mock_inspect.return_value = _insp(["git_server_configs"], []) + eng, _ = _eng() + _ensure_git_server_configs_columns(eng) +# #endregion test_git_failure_swallowed + +# #region test_auth_users [C:2] [TYPE Function] +# @BRIEF Auth users: add 2 columns, skip when present, re-raise errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_auth_users(mock_inspect, _): + mock_inspect.return_value = _insp(["users"], []) + eng, conn = _eng() + _ensure_auth_users_columns(eng) + assert conn.execute.call_count == 2 +# #endregion test_auth_users + +# #region test_auth_users_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when full_name and is_ad_user exist. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_auth_users_all_present(mock_inspect): + mock_inspect.return_value = _insp(["users"], ["full_name","is_ad_user"]) + eng = MagicMock() + _ensure_auth_users_columns(eng) + eng.begin.assert_not_called() +# #endregion test_auth_users_all_present + +# #region test_auth_users_failure_reraises [C:2] [TYPE Function] +# @BRIEF Auth users migration exception is re-raised. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("db error")) +@patch(f"{_P}.inspect") +def test_auth_users_failure_reraises(mock_inspect, _): + mock_inspect.return_value = _insp(["users"], []) + eng, _ = _eng() + with pytest.raises(RuntimeError, match="db error"): + _ensure_auth_users_columns(eng) +# #endregion test_auth_users_failure_reraises + +# #region test_roles [C:2] [TYPE Function] +# @BRIEF Roles: add is_admin + update Admin, skip when present, re-raise errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_roles(mock_inspect, _): + mock_inspect.return_value = _insp(["roles"], []) + eng, conn = _eng() + _ensure_roles_is_admin_column(eng) + assert conn.execute.call_count == 2 # ALTER + UPDATE +# #endregion test_roles + +# #region test_roles_already_exists [C:2] [TYPE Function] +# @BRIEF No ALTER when is_admin present. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_roles_already_exists(mock_inspect): + mock_inspect.return_value = _insp(["roles"], ["is_admin"]) + eng = MagicMock() + _ensure_roles_is_admin_column(eng) + eng.begin.assert_not_called() +# #endregion test_roles_already_exists + +# #region test_roles_failure_reraises [C:2] [TYPE Function] +# @BRIEF Roles migration exception is re-raised. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("fail")) +@patch(f"{_P}.inspect") +def test_roles_failure_reraises(mock_inspect, _): + mock_inspect.return_value = _insp(["roles"], []) + eng, _ = _eng() + with pytest.raises(RuntimeError, match="fail"): + _ensure_roles_is_admin_column(eng) +# #endregion test_roles_failure_reraises + +# #region test_filter_enum [C:2] [TYPE Function] +# @BRIEF FilterSource enum: skip if type missing, add missing values, swallow errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +def test_filter_enum(_): + eng, conn = _eng() + conn.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None)) + _ensure_filter_source_enum_values(eng) # type missing → skip +# #endregion test_filter_enum + +# #region test_filter_enum_adds [C:2] [TYPE Function] +# @BRIEF Adds missing enum values when type exists. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +def test_filter_enum_adds(_): + eng, conn = _eng() + type_res = MagicMock(); type_res.fetchone.return_value = ("filtersource",) + enum_res = MagicMock(); enum_res.fetchall.return_value = [("SUPERSET_PERMALINK",)] + conn.execute.side_effect = [type_res, enum_res, MagicMock()] + _ensure_filter_source_enum_values(eng) + conn.commit.assert_called_once() +# #endregion test_filter_enum_adds + +# #region test_filter_enum_complete [C:2] [TYPE Function] +# @BRIEF No ALTER TYPE when all enum values already present. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +def test_filter_enum_complete(_): + eng, conn = _eng() + type_res = MagicMock(); type_res.fetchone.return_value = ("filtersource",) + enum_res = MagicMock(); enum_res.fetchall.return_value = [ + ("SUPERSET_PERMALINK",), ("SUPERSET_NATIVE_FILTERS_KEY",)] + conn.execute.side_effect = [type_res, enum_res] + _ensure_filter_source_enum_values(eng) + conn.commit.assert_not_called() +# #endregion test_filter_enum_complete + +# #region test_filter_enum_failure [C:2] [TYPE Function] +# @BRIEF Enum migration exception is swallowed. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("pg error")) +def test_filter_enum_failure(_): + eng, _ = _eng() + _ensure_filter_source_enum_values(eng) +# #endregion test_filter_enum_failure + +# #region test_translation_jobs [C:2] [TYPE Function] +# @BRIEF Translation jobs: add 6 columns, skip when present, env_id re-raises. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_translation_jobs(mock_inspect, _): + mock_inspect.return_value = _insp(["translation_jobs"], []) + eng, conn = _eng() + _ensure_translation_jobs_columns(eng) + assert conn.execute.call_count == 6 +# #endregion test_translation_jobs + +# #region test_tj_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when all 6 translation_jobs columns exist. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_tj_all_present(mock_inspect): + cols = ["environment_id","target_database_id","target_language_column", + "target_source_column","target_source_language_column","disable_reasoning"] + mock_inspect.return_value = _insp(["translation_jobs"], cols) + eng = MagicMock() + _ensure_translation_jobs_columns(eng) + eng.begin.assert_not_called() +# #endregion test_tj_all_present + +# #region test_tj_env_failure_reraises [C:2] [TYPE Function] +# @BRIEF environment_id failure re-raises. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_tj_env_failure_reraises(mock_inspect): + mock_inspect.return_value = _insp(["translation_jobs"], []) + eng = MagicMock() + eng.begin.side_effect = RuntimeError("conn fail") + with pytest.raises(RuntimeError, match="conn fail"): + _ensure_translation_jobs_columns(eng) +# #endregion test_tj_env_failure_reraises + +# #region test_tj_partial [C:2] [TYPE Function] +# @BRIEF Only adds missing columns (4 of 6). +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_tj_partial(mock_inspect, _): + mock_inspect.return_value = _insp(["translation_jobs"], + ["environment_id","target_database_id"]) + eng, conn = _eng() + _ensure_translation_jobs_columns(eng) + assert conn.execute.call_count == 4 +# #endregion test_tj_partial + +# #region test_tj_col_failure_swallowed [C:2] [TYPE Function] +# @BRIEF Non-env column failures are swallowed. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_tj_col_failure_swallowed(mock_inspect, _): + mock_inspect.return_value = _insp(["translation_jobs"], ["environment_id"]) + eng, _ = _eng() + eng.begin.side_effect = RuntimeError("fail") + _ensure_translation_jobs_columns(eng) # must not raise +# #endregion test_tj_col_failure_swallowed + +# #region test_dataset_review [C:2] [TYPE Function] +# @BRIEF Dataset review: add 2 columns across 2 tables, re-raise errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_dataset_review(mock_inspect, _): + mock_inspect.return_value = _insp( + ["dataset_review_sessions","imported_filters"], []) + eng, conn = _eng() + _ensure_dataset_review_session_columns(eng) + assert conn.execute.call_count == 2 +# #endregion test_dataset_review + +# #region test_dr_partial_tables [C:2] [TYPE Function] +# @BRIEF Skips missing tables, migrates existing ones. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_dr_partial_tables(mock_inspect, _): + # Only dataset_review_sessions exists, imported_filters does not + mock_inspect.return_value = _insp(["dataset_review_sessions"], []) + eng, conn = _eng() + _ensure_dataset_review_session_columns(eng) + assert conn.execute.call_count == 1 # only version column for sessions +# #endregion test_dr_partial_tables + +# #region test_dr_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when version and raw_value_masked exist. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_dr_all_present(mock_inspect): + mock_inspect.return_value = _insp( + ["dataset_review_sessions","imported_filters"], ["version","raw_value_masked"]) + eng = MagicMock() + _ensure_dataset_review_session_columns(eng) + eng.begin.assert_not_called() +# #endregion test_dr_all_present + +# #region test_dr_failure_reraises [C:2] [TYPE Function] +# @BRIEF Dataset review migration exception is re-raised. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("fail")) +@patch(f"{_P}.inspect") +def test_dr_failure_reraises(mock_inspect, _): + mock_inspect.return_value = _insp( + ["dataset_review_sessions","imported_filters"], []) + eng, _ = _eng() + with pytest.raises(RuntimeError, match="fail"): + _ensure_dataset_review_session_columns(eng) +# #endregion test_dr_failure_reraises + +# #region test_schedules [C:2] [TYPE Function] +# @BRIEF Translation schedules: add execution_mode, skip when present, swallow errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_schedules(mock_inspect, _): + mock_inspect.return_value = _insp(["translation_schedules"], []) + eng, conn = _eng() + _ensure_translation_schedules_columns(eng) + assert conn.execute.call_count == 1 +# #endregion test_schedules + +# #region test_schedules_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when execution_mode exists. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_schedules_all_present(mock_inspect): + mock_inspect.return_value = _insp(["translation_schedules"], ["execution_mode"]) + eng = MagicMock() + _ensure_translation_schedules_columns(eng) + eng.begin.assert_not_called() +# #endregion test_schedules_all_present + +# #region test_schedules_failure [C:2] [TYPE Function] +# @BRIEF Schedules migration exception is swallowed. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("err")) +@patch(f"{_P}.inspect") +def test_schedules_failure(mock_inspect, _): + mock_inspect.return_value = _insp(["translation_schedules"], []) + eng, _ = _eng() + _ensure_translation_schedules_columns(eng) +# #endregion test_schedules_failure + +# #region test_dictionary [C:2] [TYPE Function] +# @BRIEF Dictionary entries: add 3 columns, skip when present, swallow errors. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=lambda s: s) +@patch(f"{_P}.inspect") +def test_dictionary(mock_inspect, _): + mock_inspect.return_value = _insp(["dictionary_entries"], []) + eng, conn = _eng() + _ensure_dictionary_entries_columns(eng) + assert conn.execute.call_count == 3 +# #endregion test_dictionary + +# #region test_dict_all_present [C:2] [TYPE Function] +# @BRIEF No ALTER when all 3 origin columns exist. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.inspect") +def test_dict_all_present(mock_inspect): + mock_inspect.return_value = _insp(["dictionary_entries"], + ["origin_run_id","origin_row_key","origin_user_id"]) + eng = MagicMock() + _ensure_dictionary_entries_columns(eng) + eng.begin.assert_not_called() +# #endregion test_dict_all_present + +# #region test_dict_failure [C:2] [TYPE Function] +# @BRIEF Dictionary migration exception is swallowed. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.text", side_effect=RuntimeError("err")) +@patch(f"{_P}.inspect") +def test_dict_failure(mock_inspect, _): + mock_inspect.return_value = _insp(["dictionary_entries"], []) + eng, _ = _eng() + _ensure_dictionary_entries_columns(eng) +# #endregion test_dict_failure + +# ═══════════════════════════════════════════════════════════════════════════ +# init_db & session generators +# ═══════════════════════════════════════════════════════════════════════════ + +# #region test_init_db [C:2] [TYPE Function] +# @BRIEF Calls create_all on all three engines. +@patch(f"{_P}.belief_scope", _NOOP) +@patch(f"{_P}.Base") +def test_init_db(mock_base): + mock_meta = MagicMock() + mock_base.metadata = mock_meta + init_db() + assert mock_meta.create_all.call_count == 3 +# #endregion test_init_db + +# #region test_session_generators [C:2] [TYPE Function] +# @BRIEF All session generators yield session and close on exit. +@pytest.mark.parametrize("gen_fn,factory_name", [ + (get_db, "SessionLocal"), (get_tasks_db, "TasksSessionLocal"), (get_auth_db, "AuthSessionLocal"), +]) +@patch(f"{_P}.belief_scope", _NOOP) +def test_session_generators(gen_fn, factory_name): + mock_sess = MagicMock() + with patch(f"{_P}.{factory_name}", return_value=mock_sess): + gen = gen_fn() + assert next(gen) is mock_sess + try: gen.send(None) + except StopIteration: pass + mock_sess.close.assert_called_once() +# #endregion test_session_generators +# #endregion Test.Core.Database diff --git a/backend/tests/core/test_core_db_executor.py b/backend/tests/core/test_core_db_executor.py new file mode 100644 index 00000000..b9f59ccd --- /dev/null +++ b/backend/tests/core/test_core_db_executor.py @@ -0,0 +1,928 @@ +# #region Test.DbExecutor [C:3] [TYPE Module] [SEMANTICS test,db,executor] +# @BRIEF Verify DbExecutor contracts — execute_sql routing, dialect executors, fetch_schema, pool caching. +# @RELATION BINDS_TO -> [Core.DbExecutor] +# @TEST_EDGE: connection_not_found -> execute_sql returns error result +# @TEST_EDGE: unsupported_dialect -> execute_sql returns error result +# @TEST_EDGE: driver_import_failure -> each dialect returns error when driver missing +# @TEST_EDGE: exception_during_execution -> execute_sql catches and returns error with timing +# @TEST_EDGE: pool_reuse -> second call reuses cached pool/client +# @TEST_EDGE: pool_invalidation -> config change (updated_at) triggers pool recreation +# @TEST_EDGE: pg_status_parsing -> various asyncpg status strings parsed correctly +# @TEST_EDGE: ch_non_int_result -> clickhouse non-int result yields rows_affected=0 +# @TEST_EDGE: fetch_schema_unknown_dialect -> returns None +# @TEST_EDGE: fetch_schema_connection_not_found -> returns None + +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from types import ModuleType + +from src.core.db_executor import DbExecutor, DbExecutionResult, DbSchemaColumn + + +# --------------------------------------------------------------------------- +# Helpers (C1 — anchor pair only) +# --------------------------------------------------------------------------- + +# #region _make_conn +def _make_conn(dialect="postgresql", conn_id="conn-1", updated_at="2025-01-01T00:00:00"): + c = MagicMock() + c.id = conn_id + c.dialect = dialect + c.host = "localhost" + c.port = 5432 + c.database = "testdb" + c.username = "user" + c.password = "pass" + c.pool_size = 5 + c.updated_at = updated_at + return c +# #endregion _make_conn + +# #region _make_service +def _make_service(conn=None): + svc = MagicMock() + svc.get_connection.return_value = conn + return svc +# #endregion _make_service + + +# =========================================================================== +# execute_sql — routing & error paths +# =========================================================================== + +class TestExecuteSqlRouting: + """Verify execute_sql dispatches to correct dialect handler and handles errors.""" + + # #region test_connection_not_found [C:2] [TYPE Function] + # @BRIEF execute_sql returns failure when connection_id is unknown. + def test_connection_not_found(self): + svc = _make_service(conn=None) + executor = DbExecutor(svc) + result = executor.execute_sql("nonexistent", "SELECT 1") + assert result.success is False + assert "not found" in result.error + # #endregion test_connection_not_found + + # #region test_unsupported_dialect [C:2] [TYPE Function] + # @BRIEF execute_sql returns failure for an unsupported dialect. + def test_unsupported_dialect(self): + conn = _make_conn(dialect="oracle") + svc = _make_service(conn) + executor = DbExecutor(svc) + result = executor.execute_sql(conn.id, "SELECT 1") + assert result.success is False + assert "Unsupported dialect" in result.error + # #endregion test_unsupported_dialect + + # #region test_exception_during_execution [C:2] [TYPE Function] + # @BRIEF execute_sql catches driver exceptions and returns error with timing. + def test_exception_during_execution(self): + conn = _make_conn(dialect="clickhouse") + svc = _make_service(conn) + executor = DbExecutor(svc) + with patch("src.core.db_executor.clickhouse_connect", create=True) as mock_mod: + # Force an ImportError to be caught inside _execute_ch's try block + # by making clickhouse_connect.get_client raise + pass + # Simpler: patch the entire _execute_ch to raise + with patch.object(executor, "_execute_ch", side_effect=RuntimeError("boom")): + result = executor.execute_sql(conn.id, "INSERT INTO t VALUES (1)") + assert result.success is False + assert "boom" in result.error + assert result.execution_time_ms >= 0 + # #endregion test_exception_during_execution + + +# =========================================================================== +# _execute_pg — PostgreSQL via asyncpg +# =========================================================================== + +class TestExecutePg: + """Verify PostgreSQL execution: pool creation, status parsing, pool reuse.""" + + # #region test_pg_success_insert [C:2] [TYPE Function] + # @BRIEF asyncpg returns 'INSERT 0 5' → rows_affected=5, success=True. + def test_pg_success_insert(self): + conn = _make_conn(dialect="postgresql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_pg_conn = AsyncMock() + mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 5") + + mock_pool_ctx = AsyncMock() + mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) + mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) + + mock_pool = MagicMock() + mock_pool.acquire.return_value = mock_pool_ctx + + mock_asyncpg = MagicMock() + mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) + + with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): + result = executor._execute_pg(conn, "INSERT INTO t VALUES (1)", 0.0) + + assert result.success is True + assert result.rows_affected == 5 + assert result.execution_time_ms >= 0 + # #endregion test_pg_success_insert + + # #region test_pg_pool_reuse [C:2] [TYPE Function] + # @BRIEF Second call with same config reuses cached pool (create_pool called once). + def test_pg_pool_reuse(self): + conn = _make_conn(dialect="postgresql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_pg_conn = AsyncMock() + mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 1") + + mock_pool_ctx = AsyncMock() + mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) + mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) + + mock_pool = MagicMock() + mock_pool.acquire.return_value = mock_pool_ctx + + mock_asyncpg = MagicMock() + mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) + + with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): + executor._execute_pg(conn, "INSERT 1", 0.0) + executor._execute_pg(conn, "INSERT 2", 0.0) + + assert mock_asyncpg.create_pool.call_count == 1 + # #endregion test_pg_pool_reuse + + # #region test_pg_pool_invalidation [C:2] [TYPE Function] + # @BRIEF Config change (updated_at) triggers pool recreation. + def test_pg_pool_invalidation(self): + conn = _make_conn(dialect="postgresql", updated_at="v1") + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_pg_conn = AsyncMock() + mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 1") + mock_pool_ctx = AsyncMock() + mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) + mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) + mock_pool = MagicMock() + mock_pool.acquire.return_value = mock_pool_ctx + + mock_asyncpg = MagicMock() + mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) + + with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): + executor._execute_pg(conn, "INSERT 1", 0.0) + # Simulate config update + conn.updated_at = "v2" + executor._execute_pg(conn, "INSERT 2", 0.0) + + assert mock_asyncpg.create_pool.call_count == 2 + # #endregion test_pg_pool_invalidation + + # #region test_pg_status_empty [C:2] [TYPE Function] + # @BRIEF asyncpg returns empty status string → rows_affected=0. + def test_pg_status_empty(self): + conn = _make_conn(dialect="postgresql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_pg_conn = AsyncMock() + mock_pg_conn.execute = AsyncMock(return_value="") + + mock_pool_ctx = AsyncMock() + mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) + mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) + mock_pool = MagicMock() + mock_pool.acquire.return_value = mock_pool_ctx + + mock_asyncpg = MagicMock() + mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) + + with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): + result = executor._execute_pg(conn, "CREATE TABLE t()", 0.0) + + assert result.success is True + assert result.rows_affected == 0 + # #endregion test_pg_status_empty + + # #region test_pg_status_non_numeric [C:2] [TYPE Function] + # @BRIEF asyncpg returns non-numeric status → rows_affected=0 (ValueError caught). + def test_pg_status_non_numeric(self): + conn = _make_conn(dialect="postgresql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_pg_conn = AsyncMock() + mock_pg_conn.execute = AsyncMock(return_value="STATEMENT") + + mock_pool_ctx = AsyncMock() + mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) + mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) + mock_pool = MagicMock() + mock_pool.acquire.return_value = mock_pool_ctx + + mock_asyncpg = MagicMock() + mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) + + with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): + result = executor._execute_pg(conn, "BEGIN", 0.0) + + assert result.success is True + assert result.rows_affected == 0 + # #endregion test_pg_status_non_numeric + + # #region test_pg_status_single_part [C:2] [TYPE Function] + # @BRIEF asyncpg returns single-word status → rows_affected=0 (len(parts) < 2). + def test_pg_status_single_part(self): + conn = _make_conn(dialect="postgresql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_pg_conn = AsyncMock() + mock_pg_conn.execute = AsyncMock(return_value="COMMIT") + + mock_pool_ctx = AsyncMock() + mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) + mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) + mock_pool = MagicMock() + mock_pool.acquire.return_value = mock_pool_ctx + + mock_asyncpg = MagicMock() + mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) + + with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): + result = executor._execute_pg(conn, "COMMIT", 0.0) + + assert result.success is True + assert result.rows_affected == 0 + # #endregion test_pg_status_single_part + + # #region test_pg_status_non_numeric_last_part [C:2] [TYPE Function] + # @BRIEF asyncpg returns multi-part status with non-numeric tail → ValueError caught, rows=0. + def test_pg_status_non_numeric_last_part(self): + conn = _make_conn(dialect="postgresql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_pg_conn = AsyncMock() + mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 abc") + + mock_pool_ctx = AsyncMock() + mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) + mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) + mock_pool = MagicMock() + mock_pool.acquire.return_value = mock_pool_ctx + + mock_asyncpg = MagicMock() + mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) + + with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): + result = executor._execute_pg(conn, "INSERT INTO t VALUES (1)", 0.0) + + assert result.success is True + assert result.rows_affected == 0 + # #endregion test_pg_status_non_numeric_last_part + + # #region test_pg_import_error [C:2] [TYPE Function] + # @BRIEF Missing asyncpg package → returns error result. + def test_pg_import_error(self): + conn = _make_conn(dialect="postgresql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + # Simulate ImportError by making asyncpg unimportable + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "asyncpg": + raise ImportError("No module named 'asyncpg'") + return real_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", side_effect=fake_import): + result = executor._execute_pg(conn, "SELECT 1", 0.0) + + assert result.success is False + assert "asyncpg" in result.error + # #endregion test_pg_import_error + + +# =========================================================================== +# _execute_ch — ClickHouse via clickhouse-connect +# =========================================================================== + +class TestExecuteCh: + """Verify ClickHouse execution: client creation, command result, pool reuse.""" + + # #region test_ch_success_int_result [C:2] [TYPE Function] + # @BRIEF clickhouse command returns int → rows_affected=int, success=True. + def test_ch_success_int_result(self): + conn = _make_conn(dialect="clickhouse", conn_id="ch-1") + # clickhouse default port + conn.port = 8123 + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_client = MagicMock() + mock_client.command.return_value = 42 + + mock_ch_mod = MagicMock() + mock_ch_mod.get_client.return_value = mock_client + + with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}): + result = executor._execute_ch(conn, "INSERT INTO t VALUES (1)", 0.0) + + assert result.success is True + assert result.rows_affected == 42 + assert result.execution_time_ms >= 0 + # #endregion test_ch_success_int_result + + # #region test_ch_non_int_result [C:2] [TYPE Function] + # @BRIEF clickhouse command returns non-int → rows_affected=0. + def test_ch_non_int_result(self): + conn = _make_conn(dialect="clickhouse", conn_id="ch-2") + conn.port = 8123 + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_client = MagicMock() + mock_client.command.return_value = "OK" + + mock_ch_mod = MagicMock() + mock_ch_mod.get_client.return_value = mock_client + + with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}): + result = executor._execute_ch(conn, "CREATE TABLE t()", 0.0) + + assert result.success is True + assert result.rows_affected == 0 + # #endregion test_ch_non_int_result + + # #region test_ch_pool_reuse [C:2] [TYPE Function] + # @BRIEF Second call reuses cached client (get_client called once). + def test_ch_pool_reuse(self): + conn = _make_conn(dialect="clickhouse", conn_id="ch-3") + conn.port = 8123 + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_client = MagicMock() + mock_client.command.return_value = 1 + + mock_ch_mod = MagicMock() + mock_ch_mod.get_client.return_value = mock_client + + with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}): + executor._execute_ch(conn, "INSERT 1", 0.0) + executor._execute_ch(conn, "INSERT 2", 0.0) + + assert mock_ch_mod.get_client.call_count == 1 + # #endregion test_ch_pool_reuse + + # #region test_ch_import_error [C:2] [TYPE Function] + # @BRIEF Missing clickhouse-connect → returns error result. + def test_ch_import_error(self): + conn = _make_conn(dialect="clickhouse", conn_id="ch-4") + conn.port = 8123 + svc = _make_service(conn) + executor = DbExecutor(svc) + + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "clickhouse_connect": + raise ImportError("No module named 'clickhouse_connect'") + return real_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", side_effect=fake_import): + result = executor._execute_ch(conn, "SELECT 1", 0.0) + + assert result.success is False + assert "clickhouse-connect" in result.error + # #endregion test_ch_import_error + + +# =========================================================================== +# _execute_mysql — MySQL via pymysql +# =========================================================================== + +class TestExecuteMysql: + """Verify MySQL execution: connection creation, cursor execution, pool reuse.""" + + # #region test_mysql_success [C:2] [TYPE Function] + # @BRIEF pymysql cursor.execute returns row count → success=True. + def test_mysql_success(self): + conn = _make_conn(dialect="mysql", conn_id="my-1") + conn.port = 3306 + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_cursor = MagicMock() + mock_cursor.execute.return_value = 3 + mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) + mock_cursor.__exit__ = MagicMock(return_value=False) + + mock_db = MagicMock() + mock_db.cursor.return_value = mock_cursor + + mock_pymysql = MagicMock() + mock_pymysql.connect.return_value = mock_db + + with patch.dict(sys.modules, {"pymysql": mock_pymysql}): + result = executor._execute_mysql(conn, "INSERT INTO t VALUES (1)", 0.0) + + assert result.success is True + assert result.rows_affected == 3 + assert result.execution_time_ms >= 0 + # #endregion test_mysql_success + + # #region test_mysql_pool_reuse [C:2] [TYPE Function] + # @BRIEF Second call reuses cached connection (connect called once). + def test_mysql_pool_reuse(self): + conn = _make_conn(dialect="mysql", conn_id="my-2") + conn.port = 3306 + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_cursor = MagicMock() + mock_cursor.execute.return_value = 1 + mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) + mock_cursor.__exit__ = MagicMock(return_value=False) + + mock_db = MagicMock() + mock_db.cursor.return_value = mock_cursor + + mock_pymysql = MagicMock() + mock_pymysql.connect.return_value = mock_db + + with patch.dict(sys.modules, {"pymysql": mock_pymysql}): + executor._execute_mysql(conn, "INSERT 1", 0.0) + executor._execute_mysql(conn, "INSERT 2", 0.0) + + assert mock_pymysql.connect.call_count == 1 + # #endregion test_mysql_pool_reuse + + # #region test_mysql_import_error [C:2] [TYPE Function] + # @BRIEF Missing pymysql → returns error result. + def test_mysql_import_error(self): + conn = _make_conn(dialect="mysql", conn_id="my-3") + conn.port = 3306 + svc = _make_service(conn) + executor = DbExecutor(svc) + + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "pymysql": + raise ImportError("No module named 'pymysql'") + return real_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", side_effect=fake_import): + result = executor._execute_mysql(conn, "SELECT 1", 0.0) + + assert result.success is False + assert "pymysql" in result.error + # #endregion test_mysql_import_error + + +# =========================================================================== +# fetch_schema — dialect routing & schema fetching +# =========================================================================== + +class TestFetchSchema: + """Verify fetch_schema dispatches to correct dialect fetcher and handles errors.""" + + # #region test_fetch_schema_connection_not_found [C:2] [TYPE Function] + # @BRIEF fetch_schema returns None when connection_id is unknown. + def test_fetch_schema_connection_not_found(self): + executor = DbExecutor(MagicMock()) + mock_svc = MagicMock() + mock_svc.get_connection.return_value = None + + with patch("src.core.connection_service.ConnectionService", return_value=mock_svc): + result = executor.fetch_schema("nonexistent", "public", "users", MagicMock()) + + assert result is None + # #endregion test_fetch_schema_connection_not_found + + # #region test_fetch_schema_clickhouse [C:2] [TYPE Function] + # @BRIEF fetch_schema routes to _fetch_ch for clickhouse dialect. + def test_fetch_schema_clickhouse(self): + conn = _make_conn(dialect="clickhouse") + executor = DbExecutor(MagicMock()) + mock_svc = MagicMock() + mock_svc.get_connection.return_value = conn + + expected = [DbSchemaColumn(name="id", data_type="UInt64")] + with patch("src.core.connection_service.ConnectionService", return_value=mock_svc), \ + patch.object(executor, "_fetch_ch", return_value=expected) as mock_fetch: + result = executor.fetch_schema("ch-1", "default", "events", MagicMock()) + + assert result == expected + mock_fetch.assert_called_once() + # Verify SQL contains system.columns + call_sql = mock_fetch.call_args[0][1] + assert "system.columns" in call_sql + # #endregion test_fetch_schema_clickhouse + + # #region test_fetch_schema_postgresql [C:2] [TYPE Function] + # @BRIEF fetch_schema routes to _fetch_pg for postgresql dialect. + def test_fetch_schema_postgresql(self): + conn = _make_conn(dialect="postgresql") + executor = DbExecutor(MagicMock()) + mock_svc = MagicMock() + mock_svc.get_connection.return_value = conn + + expected = [DbSchemaColumn(name="id", data_type="integer")] + with patch("src.core.connection_service.ConnectionService", return_value=mock_svc), \ + patch.object(executor, "_fetch_pg", return_value=expected) as mock_fetch: + result = executor.fetch_schema("pg-1", "public", "users", MagicMock()) + + assert result == expected + mock_fetch.assert_called_once() + call_sql = mock_fetch.call_args[0][1] + assert "information_schema.columns" in call_sql + # #endregion test_fetch_schema_postgresql + + # #region test_fetch_schema_mysql [C:2] [TYPE Function] + # @BRIEF fetch_schema routes to _fetch_mysql for mysql dialect. + def test_fetch_schema_mysql(self): + conn = _make_conn(dialect="mysql") + executor = DbExecutor(MagicMock()) + mock_svc = MagicMock() + mock_svc.get_connection.return_value = conn + + expected = [DbSchemaColumn(name="id", data_type="int")] + with patch("src.core.connection_service.ConnectionService", return_value=mock_svc), \ + patch.object(executor, "_fetch_mysql", return_value=expected) as mock_fetch: + result = executor.fetch_schema("my-1", "mydb", "users", MagicMock()) + + assert result == expected + mock_fetch.assert_called_once() + call_sql = mock_fetch.call_args[0][1] + assert "information_schema.columns" in call_sql + # #endregion test_fetch_schema_mysql + + # #region test_fetch_schema_unsupported_dialect [C:2] [TYPE Function] + # @BRIEF fetch_schema returns None for unsupported dialect. + def test_fetch_schema_unsupported_dialect(self): + conn = _make_conn(dialect="oracle") + executor = DbExecutor(MagicMock()) + mock_svc = MagicMock() + mock_svc.get_connection.return_value = conn + + with patch("src.core.connection_service.ConnectionService", return_value=mock_svc): + result = executor.fetch_schema("ora-1", "dbo", "users", MagicMock()) + + assert result is None + # #endregion test_fetch_schema_unsupported_dialect + + # #region test_fetch_schema_sql_injection_escaping [C:2] [TYPE Function] + # @BRIEF fetch_schema escapes single quotes in schema/table names. + def test_fetch_schema_sql_injection_escaping(self): + conn = _make_conn(dialect="postgresql") + executor = DbExecutor(MagicMock()) + mock_svc = MagicMock() + mock_svc.get_connection.return_value = conn + + with patch("src.core.connection_service.ConnectionService", return_value=mock_svc), \ + patch.object(executor, "_fetch_pg", return_value=[]) as mock_fetch: + executor.fetch_schema("pg-1", "pub'lic", "us'ers", MagicMock()) + + call_sql = mock_fetch.call_args[0][1] + assert "pub''lic" in call_sql + assert "us''ers" in call_sql + # #endregion test_fetch_schema_sql_injection_escaping + + +# =========================================================================== +# _fetch_pg — PostgreSQL schema fetch +# =========================================================================== + +class TestFetchPg: + """Verify _fetch_pg: pool creation, row mapping, import error.""" + + # #region test_fetch_pg_success [C:2] [TYPE Function] + # @BRIEF asyncpg fetch returns rows → mapped to DbSchemaColumn list. + def test_fetch_pg_success(self): + conn = _make_conn(dialect="postgresql", conn_id="pg-f1") + svc = _make_service(conn) + executor = DbExecutor(svc) + + fake_rows = [ + {"name": "id", "type": "integer"}, + {"name": "email", "type": "varchar"}, + ] + + mock_pg_conn = AsyncMock() + mock_pg_conn.fetch = AsyncMock(return_value=fake_rows) + + mock_pool_ctx = AsyncMock() + mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn) + mock_pool_ctx.__aexit__ = AsyncMock(return_value=False) + + mock_pool = MagicMock() + mock_pool.acquire.return_value = mock_pool_ctx + + mock_asyncpg = MagicMock() + mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool) + + with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}): + result = executor._fetch_pg(conn, "SELECT column_name, data_type FROM information_schema.columns") + + assert result is not None + assert len(result) == 2 + assert result[0].name == "id" + assert result[0].data_type == "integer" + assert result[1].name == "email" + assert result[1].data_type == "varchar" + # #endregion test_fetch_pg_success + + # #region test_fetch_pg_import_error [C:2] [TYPE Function] + # @BRIEF Missing asyncpg → _fetch_pg returns None. + def test_fetch_pg_import_error(self): + conn = _make_conn(dialect="postgresql", conn_id="pg-f2") + svc = _make_service(conn) + executor = DbExecutor(svc) + + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "asyncpg": + raise ImportError("No module named 'asyncpg'") + return real_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", side_effect=fake_import): + result = executor._fetch_pg(conn, "SELECT 1") + + assert result is None + # #endregion test_fetch_pg_import_error + + +# =========================================================================== +# _fetch_ch — ClickHouse schema fetch +# =========================================================================== + +class TestFetchCh: + """Verify _fetch_ch: client creation, row mapping, empty result, import error.""" + + # #region test_fetch_ch_success [C:2] [TYPE Function] + # @BRIEF clickhouse query returns rows → mapped to DbSchemaColumn list. + def test_fetch_ch_success(self): + conn = _make_conn(dialect="clickhouse", conn_id="ch-f1") + conn.port = 8123 + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_query_result = MagicMock() + mock_query_result.result_rows = [ + ("id", "UInt64"), + ("name", "String"), + ] + + mock_client = MagicMock() + mock_client.query.return_value = mock_query_result + + mock_ch_mod = MagicMock() + mock_ch_mod.get_client.return_value = mock_client + + with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}): + result = executor._fetch_ch(conn, "SELECT name, type FROM system.columns") + + assert result is not None + assert len(result) == 2 + assert result[0].name == "id" + assert result[0].data_type == "UInt64" + assert result[1].name == "name" + assert result[1].data_type == "String" + # #endregion test_fetch_ch_success + + # #region test_fetch_ch_empty_rows [C:2] [TYPE Function] + # @BRIEF clickhouse query returns empty rows → returns empty list. + def test_fetch_ch_empty_rows(self): + conn = _make_conn(dialect="clickhouse", conn_id="ch-f2") + conn.port = 8123 + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_query_result = MagicMock() + mock_query_result.result_rows = [] + + mock_client = MagicMock() + mock_client.query.return_value = mock_query_result + + mock_ch_mod = MagicMock() + mock_ch_mod.get_client.return_value = mock_client + + with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}): + result = executor._fetch_ch(conn, "SELECT name, type FROM system.columns") + + assert result == [] + # #endregion test_fetch_ch_empty_rows + + # #region test_fetch_ch_import_error [C:2] [TYPE Function] + # @BRIEF Missing clickhouse-connect → _fetch_ch returns None. + def test_fetch_ch_import_error(self): + conn = _make_conn(dialect="clickhouse", conn_id="ch-f3") + conn.port = 8123 + svc = _make_service(conn) + executor = DbExecutor(svc) + + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "clickhouse_connect": + raise ImportError("No module named 'clickhouse_connect'") + return real_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", side_effect=fake_import): + result = executor._fetch_ch(conn, "SELECT 1") + + assert result is None + # #endregion test_fetch_ch_import_error + + +# =========================================================================== +# _fetch_mysql — MySQL schema fetch +# =========================================================================== + +class TestFetchMysql: + """Verify _fetch_mysql: connection creation, fetchall mapping, empty result, import error.""" + + # #region test_fetch_mysql_success [C:2] [TYPE Function] + # @BRIEF pymysql fetchall returns rows → mapped to DbSchemaColumn list. + def test_fetch_mysql_success(self): + conn = _make_conn(dialect="mysql", conn_id="my-f1") + conn.port = 3306 + svc = _make_service(conn) + executor = DbExecutor(svc) + + fake_rows = [ + ("id", "int"), + ("name", "varchar(255)"), + ] + + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = fake_rows + mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) + mock_cursor.__exit__ = MagicMock(return_value=False) + + mock_db = MagicMock() + mock_db.cursor.return_value = mock_cursor + + mock_pymysql = MagicMock() + mock_pymysql.connect.return_value = mock_db + + with patch.dict(sys.modules, {"pymysql": mock_pymysql}): + result = executor._fetch_mysql(conn, "SELECT column_name, data_type FROM information_schema.columns") + + assert result is not None + assert len(result) == 2 + assert result[0].name == "id" + assert result[0].data_type == "int" + assert result[1].name == "name" + assert result[1].data_type == "varchar(255)" + # #endregion test_fetch_mysql_success + + # #region test_fetch_mysql_empty_rows [C:2] [TYPE Function] + # @BRIEF pymysql fetchall returns empty → returns empty list. + def test_fetch_mysql_empty_rows(self): + conn = _make_conn(dialect="mysql", conn_id="my-f2") + conn.port = 3306 + svc = _make_service(conn) + executor = DbExecutor(svc) + + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_cursor.__enter__ = MagicMock(return_value=mock_cursor) + mock_cursor.__exit__ = MagicMock(return_value=False) + + mock_db = MagicMock() + mock_db.cursor.return_value = mock_cursor + + mock_pymysql = MagicMock() + mock_pymysql.connect.return_value = mock_db + + with patch.dict(sys.modules, {"pymysql": mock_pymysql}): + result = executor._fetch_mysql(conn, "SELECT column_name, data_type FROM information_schema.columns") + + assert result == [] + # #endregion test_fetch_mysql_empty_rows + + # #region test_fetch_mysql_import_error [C:2] [TYPE Function] + # @BRIEF Missing pymysql → _fetch_mysql returns None. + def test_fetch_mysql_import_error(self): + conn = _make_conn(dialect="mysql", conn_id="my-f3") + conn.port = 3306 + svc = _make_service(conn) + executor = DbExecutor(svc) + + import builtins + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "pymysql": + raise ImportError("No module named 'pymysql'") + return real_import(name, *args, **kwargs) + + with patch.object(builtins, "__import__", side_effect=fake_import): + result = executor._fetch_mysql(conn, "SELECT 1") + + assert result is None + # #endregion test_fetch_mysql_import_error + + +# =========================================================================== +# execute_sql — full integration through dialect handlers +# =========================================================================== + +class TestExecuteSqlDialects: + """Verify execute_sql dispatches correctly to each dialect handler via mocked drivers.""" + + # #region test_execute_sql_postgresql [C:2] [TYPE Function] + # @BRIEF execute_sql routes postgresql to _execute_pg and returns its result. + def test_execute_sql_postgresql(self): + conn = _make_conn(dialect="postgresql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + expected = DbExecutionResult(success=True, rows_affected=10, execution_time_ms=50.0) + with patch.object(executor, "_execute_pg", return_value=expected) as mock_pg: + result = executor.execute_sql(conn.id, "INSERT INTO t VALUES (1)") + + assert result.success is True + assert result.rows_affected == 10 + mock_pg.assert_called_once_with(conn, "INSERT INTO t VALUES (1)", mock_pg.call_args[0][2]) + # #endregion test_execute_sql_postgresql + + # #region test_execute_sql_clickhouse [C:2] [TYPE Function] + # @BRIEF execute_sql routes clickhouse to _execute_ch and returns its result. + def test_execute_sql_clickhouse(self): + conn = _make_conn(dialect="clickhouse") + svc = _make_service(conn) + executor = DbExecutor(svc) + + expected = DbExecutionResult(success=True, rows_affected=7, execution_time_ms=30.0) + with patch.object(executor, "_execute_ch", return_value=expected) as mock_ch: + result = executor.execute_sql(conn.id, "INSERT INTO t VALUES (1)") + + assert result.success is True + assert result.rows_affected == 7 + mock_ch.assert_called_once() + # #endregion test_execute_sql_clickhouse + + # #region test_execute_sql_mysql [C:2] [TYPE Function] + # @BRIEF execute_sql routes mysql to _execute_mysql and returns its result. + def test_execute_sql_mysql(self): + conn = _make_conn(dialect="mysql") + svc = _make_service(conn) + executor = DbExecutor(svc) + + expected = DbExecutionResult(success=True, rows_affected=3, execution_time_ms=20.0) + with patch.object(executor, "_execute_mysql", return_value=expected) as mock_my: + result = executor.execute_sql(conn.id, "INSERT INTO t VALUES (1)") + + assert result.success is True + assert result.rows_affected == 3 + mock_my.assert_called_once() + # #endregion test_execute_sql_mysql + + +# =========================================================================== +# Dataclass sanity +# =========================================================================== + +class TestDataclasses: + """Verify DbExecutionResult and DbSchemaColumn default values.""" + + # #region test_db_execution_result_defaults [C:2] [TYPE Function] + # @BRIEF DbExecutionResult defaults: rows_affected=0, execution_time_ms=0.0, error=None, db_version=None. + def test_db_execution_result_defaults(self): + r = DbExecutionResult(success=True) + assert r.rows_affected == 0 + assert r.execution_time_ms == 0.0 + assert r.error is None + assert r.db_version is None + # #endregion test_db_execution_result_defaults + + # #region test_db_schema_column_defaults [C:2] [TYPE Function] + # @BRIEF DbSchemaColumn defaults: data_type=None. + def test_db_schema_column_defaults(self): + c = DbSchemaColumn(name="id") + assert c.name == "id" + assert c.data_type is None + # #endregion test_db_schema_column_defaults + +# #endregion Test.DbExecutor diff --git a/backend/tests/core/test_core_fileio.py b/backend/tests/core/test_core_fileio.py new file mode 100644 index 00000000..e73c72df --- /dev/null +++ b/backend/tests/core/test_core_fileio.py @@ -0,0 +1,561 @@ +# #region Test.Core.FileIO [C:3] [TYPE Module] [SEMANTICS test,fileio,core] +# @BRIEF Coverage tests for fileio.py missed lines: read/write/archive/retention/yaml/async/consolidate. +# @RELATION BINDS_TO -> [FileIOModule] +# @TEST_EDGE: missing_file -> FileNotFoundError / assertion +# @TEST_EDGE: invalid_type -> InvalidZipFormatError on bad ZIP content +# @TEST_EDGE: external_fail -> OSError on cleanup / permission denied + +from datetime import date, timedelta +from pathlib import Path +from unittest.mock import MagicMock, patch +import io +import os +import zipfile + +import pytest + +from src.core.utils.fileio import ( + InvalidZipFormatError, + RetentionPolicy, + _update_yaml_file, + apply_retention_policy, + archive_exports, + asave_and_unpack_dashboard, + aread_dashboard_from_disk, + consolidate_archive_folders, + create_dashboard_export, + create_temp_file, + get_filename_from_headers, + read_dashboard_from_disk, + remove_empty_directories, + save_and_unpack_dashboard, + update_yamls, + _unzip_to_directory, +) + + +def _make_zip_bytes(files=None): + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in (files or [("f.txt", b"data")]): + zf.writestr(name, data) + return buf.getvalue() + + +# ── create_temp_file: dir cleanup (58-59) + OSError (63-64) ────────── + +# #region test_create_temp_dir_cleanup [C:2] [TYPE Function] +# @BRIEF Verify shutil.rmtree cleanup in finally block (lines 58-59). +def test_create_temp_dir_cleanup(): + import tempfile as _tf + real_td = _tf.TemporaryDirectory + class PassthroughDir: + """Context manager that creates a temp dir but does NOT auto-cleanup.""" + def __init__(self, **kw): + self._d = real_td(**kw) + self.name = self._d.name + def __enter__(self): + return self._d.__enter__() + def __exit__(self, *a): + self._d._finalizer.detach() # prevent auto-cleanup + self._d._finalizer = None + return False + saved = None + with patch("src.core.utils.fileio.tempfile.TemporaryDirectory", PassthroughDir): + with create_temp_file(suffix=".dir") as p: + saved = Path(str(p)) + assert saved.is_dir() + assert not saved.exists() +# #endregion test_create_temp_dir_cleanup + +# #region test_create_temp_cleanup_oserror [C:2] [TYPE Function] +# @BRIEF Verify OSError during cleanup is caught (lines 63-64). +def test_create_temp_cleanup_oserror(monkeypatch): + orig_unlink = Path.unlink + def fail_unlink(self): + raise OSError("permission denied") + monkeypatch.setattr(Path, "unlink", fail_unlink) + with create_temp_file(content=b"x", suffix=".txt") as p: + saved = p + assert saved.exists() + orig_unlink(saved) +# #endregion test_create_temp_cleanup_oserror + +# ── remove_empty_directories: not-found (76-77) + OSError (84-85) ──── + +# #region test_remove_empty_dirs_not_found [C:2] [TYPE Function] +# @BRIEF Non-existent directory returns 0 (lines 76-77). +def test_remove_empty_dirs_not_found(): + assert remove_empty_directories("/nonexistent/path/xyz") == 0 +# #endregion test_remove_empty_dirs_not_found + +# #region test_remove_empty_dirs_oserror [C:2] [TYPE Function] +# @BRIEF OSError during rmdir is caught (lines 84-85). +def test_remove_empty_dirs_oserror(monkeypatch, tmp_path): + d = tmp_path / "empty" + d.mkdir() + monkeypatch.setattr(os, "rmdir", MagicMock(side_effect=OSError("fail"))) + assert remove_empty_directories(str(tmp_path)) == 0 +# #endregion test_remove_empty_dirs_oserror + +# ── read_dashboard_from_disk (95-102) ───────────────────────────────── + +# #region test_read_dashboard_from_disk [C:2] [TYPE Function] +# @BRIEF Reads file content and returns (bytes, name) (lines 95-102). +def test_read_dashboard_from_disk(tmp_path): + f = tmp_path / "dash.zip" + f.write_bytes(b"\x00\x01\x02") + content, name = read_dashboard_from_disk(str(f)) + assert content == b"\x00\x01\x02" + assert name == "dash.zip" +# #endregion test_read_dashboard_from_disk + +# #region test_read_dashboard_empty_file [C:2] [TYPE Function] +# @BRIEF Empty file returns empty bytes (lines 100-101). +def test_read_dashboard_empty_file(tmp_path): + f = tmp_path / "empty.zip" + f.write_bytes(b"") + content, _ = read_dashboard_from_disk(str(f)) + assert content == b"" +# #endregion test_read_dashboard_empty_file + +# #region test_read_dashboard_not_found [C:2] [TYPE Function] +# @BRIEF Missing file raises AssertionError (line 97). +def test_read_dashboard_not_found(): + with pytest.raises(AssertionError, match="не найден"): + read_dashboard_from_disk("/no/such/file.zip") +# #endregion test_read_dashboard_not_found + +# ── apply_retention_policy (205-229) ────────────────────────────────── + +# #region test_apply_retention_policy_buckets [C:2] [TYPE Function] +# @BRIEF Files bucketed into daily/weekly/monthly; too-old excluded (lines 205-229). +def test_apply_retention_policy_buckets(tmp_path, monkeypatch): + today = date(2025, 6, 1) + monkeypatch.setattr("src.core.utils.fileio.date", MagicMock(today=MagicMock(return_value=today))) + files = [] + for days_ago in [1, 3, 10, 20, 60, 120, 400]: + p = tmp_path / f"f_{days_ago}.zip" + p.write_bytes(b"x") + files.append((p, today - timedelta(days=days_ago))) + policy = RetentionPolicy(daily=7, weekly=4, monthly=12) + kept = apply_retention_policy(files, policy) + assert (tmp_path / "f_1.zip") in kept + assert (tmp_path / "f_3.zip") in kept + assert (tmp_path / "f_10.zip") in kept + assert (tmp_path / "f_400.zip") not in kept +# #endregion test_apply_retention_policy_buckets + +# ── archive_exports (131-197) ───────────────────────────────────────── + +# #region test_archive_exports_no_dir [C:2] [TYPE Function] +# @BRIEF Non-existent dir returns early (lines 133-135). +def test_archive_exports_no_dir(): + archive_exports("/no/such/dir", RetentionPolicy()) +# #endregion test_archive_exports_no_dir + +# #region test_archive_exports_no_zips [C:2] [TYPE Function] +# @BRIEF Empty dir returns early (lines 140-142). +def test_archive_exports_no_zips(tmp_path): + archive_exports(str(tmp_path), RetentionPolicy()) +# #endregion test_archive_exports_no_zips + +# #region test_archive_exports_dedup [C:2] [TYPE Function] +# @BRIEF Deduplication removes older copy; retention cleans the rest (lines 144-197). +def test_archive_exports_dedup(tmp_path): + z = _make_zip_bytes([("a.txt", b"same")]) + (tmp_path / "old_20250101_120000.zip").write_bytes(z) + (tmp_path / "new_20250501_120000.zip").write_bytes(z) + archive_exports(str(tmp_path), RetentionPolicy(daily=365), deduplicate=True) + remaining = list(tmp_path.glob("*.zip")) + assert len(remaining) <= 1 +# #endregion test_archive_exports_dedup + +# ── save_and_unpack_dashboard (237-254) ─────────────────────────────── + +# #region test_save_and_unpack_basic [C:2] [TYPE Function] +# @BRIEF Save without unpack returns (path, None) (lines 237-251). +def test_save_and_unpack_basic(tmp_path): + z = _make_zip_bytes() + zp, out = save_and_unpack_dashboard(z, str(tmp_path / "out"), original_filename="test.zip") + assert zp.exists() and zp.name == "test.zip" + assert out is None +# #endregion test_save_and_unpack_basic + +# #region test_save_and_unpack_with_unpack [C:2] [TYPE Function] +# @BRIEF Save + unpack extracts files (lines 246-250). +def test_save_and_unpack_with_unpack(tmp_path): + z = _make_zip_bytes([("inner.txt", b"hello")]) + zp, out = save_and_unpack_dashboard(z, str(tmp_path / "out"), unpack=True) + assert out is not None + assert (out / "inner.txt").read_bytes() == b"hello" +# #endregion test_save_and_unpack_with_unpack + +# #region test_save_and_unpack_invalid_zip [C:2] [TYPE Function] +# @BRIEF Bad ZIP raises InvalidZipFormatError (lines 252-254). +def test_save_and_unpack_invalid_zip(tmp_path): + with pytest.raises(InvalidZipFormatError): + save_and_unpack_dashboard(b"not-a-zip", str(tmp_path), unpack=True) +# #endregion test_save_and_unpack_invalid_zip + +# ── update_yamls + _update_yaml_file (263-333) ─────────────────────── + +# #region test_update_yamls_basic [C:2] [TYPE Function] +# @BRIEF update_yamls iterates YAML files and delegates (lines 263-271). +def test_update_yamls_basic(tmp_path): + (tmp_path / "a.yaml").write_text("host: old_server\nport: 3306") + update_yamls(db_configs=[{"old": {"host": "old_server"}, "new": {"host": "new_server"}}], path=str(tmp_path)) + assert "new_server" in (tmp_path / "a.yaml").read_text() +# #endregion test_update_yamls_basic + +# #region test_update_yaml_regex [C:2] [TYPE Function] +# @BRIEF Regex replacement in YAML file (lines 287-295). +def test_update_yaml_regex(tmp_path): + f = tmp_path / "c.yaml" + f.write_text("value: old_text") + _update_yaml_file(f, [], r"old_text", "new_text") + assert "new_text" in f.read_text() +# #endregion test_update_yaml_regex + +# #region test_update_yaml_configs [C:2] [TYPE Function] +# @BRIEF Config-based old→new replacement (lines 297-333). +def test_update_yaml_configs(tmp_path): + f = tmp_path / "d.yaml" + f.write_text('host: "old_host"\nport: "3306"') + _update_yaml_file(f, [{"old": {"host": "old_host"}, "new": {"host": "new_host"}}], None, None) + result = f.read_text() + assert "new_host" in result + assert "old_host" not in result +# #endregion test_update_yaml_configs + +# #region test_update_yaml_read_error [C:2] [TYPE Function] +# @BRIEF Read error is caught and logged (lines 283-285). +def test_update_yaml_read_error(tmp_path): + f = tmp_path / "bad.yaml" + f.write_text("x: 1") + with patch("builtins.open", side_effect=IOError("fail")): + _update_yaml_file(f, [], None, None) +# #endregion test_update_yaml_read_error + +# #region test_update_yaml_regex_no_change [C:2] [TYPE Function] +# @BRIEF Regex that doesn't match skips rewrite (line 290). +def test_update_yaml_regex_no_change(tmp_path): + f = tmp_path / "e.yaml" + f.write_text("value: unchanged") + _update_yaml_file(f, [], r"NONEXISTENT_PATTERN", "replacement") + assert f.read_text() == "value: unchanged" +# #endregion test_update_yaml_regex_no_change + +# ── consolidate_archive_folders (417, 422, 425-426, 431-432) ───────── + +# #region test_consolidate_skip_target [C:2] [TYPE Function] +# @BRIEF source_dir == target_dir hits continue (line 417). +def test_consolidate_skip_target(tmp_path): + (tmp_path / "sales").mkdir() + (tmp_path / "sales" / "a.zip").write_bytes(b"z") + (tmp_path / "sales_2024").mkdir() + (tmp_path / "sales_2024" / "b.zip").write_bytes(b"z") + consolidate_archive_folders(tmp_path) + assert (tmp_path / "sales" / "a.zip").exists() +# #endregion test_consolidate_skip_target + +# #region test_consolidate_move_dir [C:2] [TYPE Function] +# @BRIEF shutil.move for subdirectory (line 422). +def test_consolidate_move_dir(tmp_path): + (tmp_path / "a_1").mkdir() + (tmp_path / "a_1" / "f.zip").write_bytes(b"z") + (tmp_path / "a_1" / "sub").mkdir() + (tmp_path / "a_2").mkdir() + (tmp_path / "a_2" / "g.zip").write_bytes(b"z") + consolidate_archive_folders(tmp_path) + assert (tmp_path / "a").is_dir() +# #endregion test_consolidate_move_dir + +# #region test_consolidate_move_error [C:2] [TYPE Function] +# @BRIEF Exception during shutil.move is caught (lines 425-426). +def test_consolidate_move_error(tmp_path): + (tmp_path / "x_1").mkdir() + (tmp_path / "x_1" / "f.zip").write_bytes(b"z") + (tmp_path / "x_1" / "sub").mkdir() + (tmp_path / "x_2").mkdir() + (tmp_path / "x_2" / "g.zip").write_bytes(b"z") + real_move = __import__("shutil").move + def fail_for_subdir(src, dst): + if "sub" in str(src): + raise OSError("move fail") + return real_move(src, dst) + with patch("src.core.utils.fileio.shutil.move", side_effect=fail_for_subdir): + consolidate_archive_folders(tmp_path) +# #endregion test_consolidate_move_error + +# #region test_consolidate_rmdir_error [C:2] [TYPE Function] +# @BRIEF Exception during source rmdir is caught (lines 431-432). +def test_consolidate_rmdir_error(tmp_path): + (tmp_path / "r_1").mkdir() + (tmp_path / "r_1" / "a.zip").write_bytes(b"z") + (tmp_path / "r_2").mkdir() + (tmp_path / "r_2" / "b.zip").write_bytes(b"z") + (tmp_path / "r_2" / "sub").mkdir() + (tmp_path / "r_2" / "sub" / "keep.txt").write_text("x") + real_move = __import__("shutil").move + def fail_sub(src, dst): + if "sub" in str(src): + raise OSError("nope") + return real_move(src, dst) + with patch("src.core.utils.fileio.shutil.move", side_effect=fail_sub): + consolidate_archive_folders(tmp_path) +# #endregion test_consolidate_rmdir_error + +# ── aread_dashboard_from_disk (457, 459-460, 464) ──────────────────── + +# #region test_aread_not_found [C:2] [TYPE Function] +# @BRIEF Missing file raises FileNotFoundError (line 457). +@pytest.mark.asyncio +async def test_aread_not_found(): + with pytest.raises(FileNotFoundError): + await aread_dashboard_from_disk("/no/such/file.zip") +# #endregion test_aread_not_found + +# #region test_aread_large_file_aiofiles [C:2] [TYPE Function] +# @BRIEF File >10MB uses aiofiles path (lines 459-460). +@pytest.mark.asyncio +async def test_aread_large_file_aiofiles(tmp_path): + from src.core.utils.executors import init_executors, shutdown_executors + init_executors(file_workers=2) + try: + big = tmp_path / "big.zip" + big.write_bytes(b"A" * (11 * 1024 * 1024)) + content = await aread_dashboard_from_disk(str(big)) + assert len(content) == 11 * 1024 * 1024 + finally: + shutdown_executors() +# #endregion test_aread_large_file_aiofiles + +# #region test_aread_empty_file [C:2] [TYPE Function] +# @BRIEF Empty file triggers warning (line 464). +@pytest.mark.asyncio +async def test_aread_empty_file(tmp_path): + from src.core.utils.executors import init_executors, shutdown_executors + init_executors(file_workers=2) + try: + f = tmp_path / "empty.zip" + f.write_bytes(b"") + content = await aread_dashboard_from_disk(str(f)) + assert content == b"" + finally: + shutdown_executors() +# #endregion test_aread_empty_file + +# ── asave_and_unpack_dashboard (480-508) ────────────────────────────── + +# #region test_asave_basic [C:2] [TYPE Function] +# @BRIEF Async save without unpack (lines 480-508). +@pytest.mark.asyncio +async def test_asave_basic(tmp_path): + from src.core.utils.executors import init_executors, shutdown_executors + init_executors(file_workers=2) + try: + z = _make_zip_bytes() + zp, out = await asave_and_unpack_dashboard(z, str(tmp_path / "out"), original_filename="a.zip") + assert zp.exists() and zp.name == "a.zip" + assert out is None + finally: + shutdown_executors() +# #endregion test_asave_basic + +# #region test_asave_with_unpack [C:2] [TYPE Function] +# @BRIEF Async save + unpack extracts files (lines 497-507). +@pytest.mark.asyncio +async def test_asave_with_unpack(tmp_path): + from src.core.utils.executors import init_executors, shutdown_executors + init_executors(file_workers=2) + try: + z = _make_zip_bytes([("inner.txt", b"hello")]) + zp, out = await asave_and_unpack_dashboard(z, str(tmp_path / "out"), unpack=True) + assert out is not None + assert (out / "inner.txt").read_bytes() == b"hello" + finally: + shutdown_executors() +# #endregion test_asave_with_unpack + +# #region test_asave_invalid_zip [C:2] [TYPE Function] +# @BRIEF Invalid ZIP during unpack propagates error (line 500). +@pytest.mark.asyncio +async def test_asave_invalid_zip(tmp_path): + from src.core.utils.executors import init_executors, shutdown_executors + init_executors(file_workers=2) + try: + with pytest.raises(zipfile.BadZipFile): + await asave_and_unpack_dashboard(b"bad", str(tmp_path / "out"), unpack=True) + finally: + shutdown_executors() +# #endregion test_asave_invalid_zip + +# ── _unzip_to_directory (515-516) ───────────────────────────────────── + +# #region test_unzip_to_directory [C:2] [TYPE Function] +# @BRIEF Helper extracts ZIP contents (lines 515-516). +def test_unzip_to_directory(tmp_path): + z = _make_zip_bytes([("a.txt", b"ok")]) + zp = tmp_path / "t.zip" + zp.write_bytes(z) + out = tmp_path / "out" + out.mkdir() + _unzip_to_directory(str(zp), str(out)) + assert (out / "a.txt").read_bytes() == b"ok" +# #endregion test_unzip_to_directory + +# ── Additional edge-case coverage ───────────────────────────────────── + +# #region test_create_temp_no_content_branch [C:2] [TYPE Function] +# @BRIEF No-content branch: file created empty, cleanup runs (branch 50->52, line 62). +def test_create_temp_no_content_branch(): + saved = None + with create_temp_file(suffix=".txt") as p: + saved = p + assert p.stat().st_size == 0 + assert not saved.exists() +# #endregion test_create_temp_no_content_branch + +# #region test_remove_empty_dirs_success [C:2] [TYPE Function] +# @BRIEF Successful removal increments counter (lines 82-83). +def test_remove_empty_dirs_success(tmp_path): + (tmp_path / "e1").mkdir() + (tmp_path / "e2").mkdir() + (tmp_path / "e2" / "nested").mkdir() + count = remove_empty_directories(str(tmp_path)) + assert count >= 2 +# #endregion test_remove_empty_dirs_success + +# #region test_archive_exports_retention_no_dedup [C:2] [TYPE Function] +# @BRIEF Retention without dedup: mtime fallback + old file removed (lines 144→171, 186, 192-197). +def test_archive_exports_retention_no_dedup(tmp_path): + z = _make_zip_bytes() + old = tmp_path / "nodate_old.zip" + old.write_bytes(z) + old.touch() + archive_exports(str(tmp_path), RetentionPolicy(daily=0, weekly=0, monthly=0)) +# #endregion test_archive_exports_retention_no_dedup + +# #region test_archive_exports_dedup_crc_error [C:2] [TYPE Function] +# @BRIEF CRC32 calculation error is caught (lines 160-161). +def test_archive_exports_dedup_crc_error(tmp_path): + z = _make_zip_bytes() + (tmp_path / "a_20250501_120000.zip").write_bytes(z) + with patch("src.core.utils.fileio.calculate_crc32", side_effect=Exception("crc fail")): + archive_exports(str(tmp_path), RetentionPolicy(daily=365), deduplicate=True) +# #endregion test_archive_exports_dedup_crc_error + +# #region test_archive_exports_dedup_unlink_error [C:2] [TYPE Function] +# @BRIEF Duplicate unlink OSError is caught (lines 168-169). +def test_archive_exports_dedup_unlink_error(tmp_path): + z = _make_zip_bytes() + (tmp_path / "old_20250101_120000.zip").write_bytes(z) + (tmp_path / "new_20250501_120000.zip").write_bytes(z) + real_unlink = Path.unlink + def fail_old(self): + if "old_" in str(self): + raise OSError("unlink fail") + return real_unlink(self) + with patch.object(Path, "unlink", fail_old): + archive_exports(str(tmp_path), RetentionPolicy(daily=365), deduplicate=True) + assert (tmp_path / "old_20250101_120000.zip").exists() +# #endregion test_archive_exports_dedup_unlink_error + +# #region test_archive_exports_invalid_date [C:2] [TYPE Function] +# @BRIEF Invalid date in filename falls back to mtime (lines 181-182). +def test_archive_exports_invalid_date(tmp_path): + z = _make_zip_bytes() + (tmp_path / "x_99999999_120000.zip").write_bytes(z) + archive_exports(str(tmp_path), RetentionPolicy(daily=365), deduplicate=True) +# #endregion test_archive_exports_invalid_date + +# #region test_update_yaml_regex_error [C:2] [TYPE Function] +# @BRIEF Invalid regex pattern is caught (lines 294-295). +def test_update_yaml_regex_error(tmp_path): + f = tmp_path / "bad_re.yaml" + f.write_text("value: test") + _update_yaml_file(f, [], r"[invalid(", "replacement") + assert f.read_text() == "value: test" +# #endregion test_update_yaml_regex_error + +# #region test_update_yaml_config_error [C:2] [TYPE Function] +# @BRIEF Config replacement error is caught (lines 332-333). +def test_update_yaml_config_error(tmp_path): + f = tmp_path / "cfg_err.yaml" + f.write_text("key: val") + with patch("re.sub", side_effect=RuntimeError("sub fail")): + _update_yaml_file(f, [{"old": {"key": "val"}, "new": {"key": "new"}}], None, None) +# #endregion test_update_yaml_config_error + +# #region test_consolidate_single_slug [C:2] [TYPE Function] +# @BRIEF Single-dir slug hits continue (line 409). +def test_consolidate_single_slug(tmp_path): + (tmp_path / "lonely_2024").mkdir() + (tmp_path / "lonely_2024" / "a.zip").write_bytes(b"z") + consolidate_archive_folders(tmp_path) + assert (tmp_path / "lonely_2024").exists() +# #endregion test_consolidate_single_slug + +# #region test_acleanup_directory [C:2] [TYPE Function] +# @BRIEF Async directory cleanup via run_blocking (lines 527-528). +@pytest.mark.asyncio +async def test_acleanup_directory(tmp_path): + from src.core.utils.fileio import acleanup_directory + from src.core.utils.executors import init_executors, shutdown_executors + init_executors(file_workers=2) + try: + d = tmp_path / "to_clean" + d.mkdir() + (d / "f.txt").write_text("x") + await acleanup_directory(str(d)) + assert not d.exists() + finally: + shutdown_executors() +# #endregion test_acleanup_directory + +# #region test_aread_bytes_func [C:2] [TYPE Function] +# @BRIEF Async read bytes via run_blocking (lines 538-539). +@pytest.mark.asyncio +async def test_aread_bytes_func(tmp_path): + from src.core.utils.fileio import aread_bytes + from src.core.utils.executors import init_executors, shutdown_executors + init_executors(file_workers=2) + try: + f = tmp_path / "data.bin" + f.write_bytes(b"\xde\xad\xbe\xef") + result = await aread_bytes(f) + assert result == b"\xde\xad\xbe\xef" + finally: + shutdown_executors() +# #endregion test_aread_bytes_func + +# ── create_dashboard_export (341-357) + get_filename_from_headers (374-378) ─ + +# #region test_create_dashboard_export_basic [C:2] [TYPE Function] +# @BRIEF Creates ZIP from source paths (lines 341-357). +def test_create_dashboard_export_basic(tmp_path): + src = tmp_path / "src" + src.mkdir() + (src / "dash.json").write_text("{}") + (src / "meta.yaml").write_text("v: 1") + zp = tmp_path / "export.zip" + assert create_dashboard_export(zp, [str(src)]) is True + assert zp.exists() + with zipfile.ZipFile(zp) as zf: + assert any("dash.json" in n for n in zf.namelist()) +# #endregion test_create_dashboard_export_basic + +# #region test_create_dashboard_export_fail [C:2] [TYPE Function] +# @BRIEF Non-existent source returns False (lines 355-357). +def test_create_dashboard_export_fail(): + assert create_dashboard_export("/tmp/x.zip", ["/nonexistent"]) is False +# #endregion test_create_dashboard_export_fail + +# #region test_get_filename_from_headers_cov [C:2] [TYPE Function] +# @BRIEF Extracts filename from Content-Disposition header (lines 374-378). +def test_get_filename_from_headers_cov(): + assert get_filename_from_headers({"Content-Disposition": 'attachment; filename="r.pdf"'}) == "r.pdf" + assert get_filename_from_headers({}) is None +# #endregion test_get_filename_from_headers_cov +# #endregion Test.Core.FileIO diff --git a/backend/tests/core/test_encryption.py b/backend/tests/core/test_encryption.py index f873f20e..01d5d50a 100644 --- a/backend/tests/core/test_encryption.py +++ b/backend/tests/core/test_encryption.py @@ -1,112 +1,171 @@ # #region Test.Encryption [C:3] [TYPE Module] [SEMANTICS test,encryption,fernet,key,crypto] -# @BRIEF Tests for core/encryption.py and core/encryption_key.py — Fernet key loading, encrypt/decrypt. +# @BRIEF Verify EncryptionManager encrypt/decrypt and ensure_encryption_key resolution contracts. # @RELATION BINDS_TO -> [EncryptionCore] +# @RELATION BINDS_TO -> [EncryptionKeyModule] +# @TEST_EDGE: missing_key -> RuntimeError when ENCRYPTION_KEY absent +# @TEST_EDGE: invalid_key -> RuntimeError when ENCRYPTION_KEY is not valid Fernet format +# @TEST_EDGE: external_fail -> decrypt of garbage data raises Exception +# @TEST_INVARIANT: symmetric_encryption -> VERIFIED_BY: encrypt_decrypt_cycle, empty_string_encryption from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -import os -import tempfile -from unittest.mock import patch, MagicMock import pytest +# Hardcoded valid Fernet key — generated once, frozen as fixture. +# 32 url-safe base64 bytes. Never recompute at test time (anti-tautology). +_VALID_FERNET_KEY = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" + + +# ── _require_fernet_key ───────────────────────────────────────────────────── class TestRequireFernetKey: """_require_fernet_key — loads and validates Fernet key from environment.""" - def test_valid_key(self): + # #region test_require_key_valid [C:2] [TYPE Function] + # @BRIEF Valid ENCRYPTION_KEY returns key bytes without error. + def test_require_key_valid(self, monkeypatch): from src.core.encryption import _require_fernet_key - key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" - with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): - result = _require_fernet_key() - assert result == key.encode() - def test_missing_key_raises(self): + monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY) + result = _require_fernet_key() + assert result == _VALID_FERNET_KEY.encode() + # #endregion test_require_key_valid + + # #region test_require_key_missing [C:2] [TYPE Function] + # @BRIEF Missing ENCRYPTION_KEY raises RuntimeError with startup guidance. + def test_require_key_missing(self, monkeypatch): from src.core.encryption import _require_fernet_key - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"): - _require_fernet_key() - def test_invalid_key_raises(self): + monkeypatch.delenv("ENCRYPTION_KEY", raising=False) + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"): + _require_fernet_key() + # #endregion test_require_key_missing + + # #region test_require_key_invalid [C:2] [TYPE Function] + # @BRIEF Malformed ENCRYPTION_KEY raises RuntimeError about valid Fernet key. + def test_require_key_invalid(self, monkeypatch): from src.core.encryption import _require_fernet_key - with patch.dict(os.environ, {"ENCRYPTION_KEY": "not-a-valid-fernet-key"}, clear=True): - with pytest.raises(RuntimeError, match="valid Fernet key"): - _require_fernet_key() - def test_empty_key_raises(self): + monkeypatch.setenv("ENCRYPTION_KEY", "not-a-valid-fernet-key") + with pytest.raises(RuntimeError, match="valid Fernet key"): + _require_fernet_key() + # #endregion test_require_key_invalid + + # #region test_require_key_whitespace_only [C:2] [TYPE Function] + # @BRIEF Whitespace-only ENCRYPTION_KEY is treated as missing. + def test_require_key_whitespace_only(self, monkeypatch): from src.core.encryption import _require_fernet_key - with patch.dict(os.environ, {"ENCRYPTION_KEY": " "}, clear=True): - with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"): - _require_fernet_key() + monkeypatch.setenv("ENCRYPTION_KEY", " ") + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"): + _require_fernet_key() + # #endregion test_require_key_whitespace_only + + +# ── EncryptionManager ─────────────────────────────────────────────────────── class TestEncryptionManager: - """EncryptionManager — encrypt and decrypt operations.""" + """EncryptionManager — encrypt/decrypt operations with validated Fernet key.""" - def test_encrypt_decrypt_cycle(self): + # #region test_encrypt_decrypt_cycle [C:2] [TYPE Function] + # @BRIEF Encrypt then decrypt returns original plaintext (symmetric invariant). + def test_encrypt_decrypt_cycle(self, monkeypatch): from src.core.encryption import EncryptionManager - key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" - with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): - mgr = EncryptionManager() - encrypted = mgr.encrypt("my_secret_data") - assert encrypted != "my_secret_data" - decrypted = mgr.decrypt(encrypted) - assert decrypted == "my_secret_data" - def test_empty_string_encryption(self): + monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY) + mgr = EncryptionManager() + # Hardcoded fixture — plaintext declared before assertion, not computed. + plaintext = "my_secret_key" + encrypted = mgr.encrypt(plaintext) + assert encrypted != plaintext + assert mgr.decrypt(encrypted) == plaintext + # #endregion test_encrypt_decrypt_cycle + + # #region test_empty_string_encryption [C:2] [TYPE Function] + # @BRIEF Empty string survives encrypt/decrypt cycle. + def test_empty_string_encryption(self, monkeypatch): from src.core.encryption import EncryptionManager - key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" - with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): - mgr = EncryptionManager() - encrypted = mgr.encrypt("") - decrypted = mgr.decrypt(encrypted) - assert decrypted == "" - def test_decrypt_invalid_data_raises(self): + monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY) + mgr = EncryptionManager() + encrypted = mgr.encrypt("") + assert mgr.decrypt(encrypted) == "" + # #endregion test_empty_string_encryption + + # #region test_decrypt_garbage_raises [C:2] [TYPE Function] + # @BRIEF Decrypting non-Fernet data raises an exception (external_fail edge). + def test_decrypt_garbage_raises(self, monkeypatch): from src.core.encryption import EncryptionManager - key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" - with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): - mgr = EncryptionManager() - with pytest.raises(Exception): - mgr.decrypt("not-encrypted-data") + monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY) + mgr = EncryptionManager() + with pytest.raises(Exception): + mgr.decrypt("not-encrypted-data") + # #endregion test_decrypt_garbage_raises + + # #region test_init_missing_key_raises [C:2] [TYPE Function] + # @BRIEF EncryptionManager() without ENCRYPTION_KEY raises RuntimeError at init. + def test_init_missing_key_raises(self, monkeypatch): + from src.core.encryption import EncryptionManager + + monkeypatch.delenv("ENCRYPTION_KEY", raising=False) + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"): + EncryptionManager() + # #endregion test_init_missing_key_raises + + +# ── ensure_encryption_key ─────────────────────────────────────────────────── class TestEnsureEncryptionKey: - """ensure_encryption_key — resolve key from env or .env file.""" + """ensure_encryption_key — resolve key from env or .env file, crash-early if absent.""" - def test_from_environment(self): + # #region test_ensure_key_from_env [C:2] [TYPE Function] + # @BRIEF Returns key from process environment when ENCRYPTION_KEY is set. + def test_ensure_key_from_env(self, monkeypatch): from src.core.encryption_key import ensure_encryption_key - key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" - with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True): - result = ensure_encryption_key() - assert result == key - def test_from_env_file(self): - from src.core.encryption_key import ensure_encryption_key - key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=" - with patch.dict(os.environ, {}, clear=True): - with tempfile.TemporaryDirectory() as tmpdir: - env_file = Path(tmpdir) / ".env" - env_file.write_text(f"ENCRYPTION_KEY={key}\nOTHER=value\n") - result = ensure_encryption_key(env_file) - assert result == key - # Should also set it in environment - assert os.environ.get("ENCRYPTION_KEY") == key + monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY) + result = ensure_encryption_key() + assert result == _VALID_FERNET_KEY + # #endregion test_ensure_key_from_env - def test_env_file_not_found_raises(self): + # #region test_ensure_key_from_dotenv_file [C:2] [TYPE Function] + # @BRIEF Loads key from .env file when not in process environment; sets os.environ. + def test_ensure_key_from_dotenv_file(self, monkeypatch, tmp_path): from src.core.encryption_key import ensure_encryption_key - with patch.dict(os.environ, {}, clear=True): - with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"): - ensure_encryption_key(Path("/nonexistent/.env")) - def test_env_file_without_key_raises(self): + monkeypatch.delenv("ENCRYPTION_KEY", raising=False) + env_file = tmp_path / ".env" + env_file.write_text(f"ENCRYPTION_KEY={_VALID_FERNET_KEY}\nOTHER=value\n") + result = ensure_encryption_key(env_file) + assert result == _VALID_FERNET_KEY + # Side-effect: key must be propagated to process environment. + import os + assert os.environ.get("ENCRYPTION_KEY") == _VALID_FERNET_KEY + # #endregion test_ensure_key_from_dotenv_file + + # #region test_ensure_key_no_file_raises [C:2] [TYPE Function] + # @BRIEF Crash-early when no .env file exists and env var is unset. + def test_ensure_key_no_file_raises(self, monkeypatch): from src.core.encryption_key import ensure_encryption_key - with patch.dict(os.environ, {}, clear=True): - with tempfile.TemporaryDirectory() as tmpdir: - env_file = Path(tmpdir) / ".env" - env_file.write_text("OTHER=value\n") - with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"): - ensure_encryption_key(env_file) + + monkeypatch.delenv("ENCRYPTION_KEY", raising=False) + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"): + ensure_encryption_key(Path("/nonexistent/.env")) + # #endregion test_ensure_key_no_file_raises + + # #region test_ensure_key_file_without_key_raises [C:2] [TYPE Function] + # @BRIEF Crash-early when .env file exists but contains no ENCRYPTION_KEY line. + def test_ensure_key_file_without_key_raises(self, monkeypatch, tmp_path): + from src.core.encryption_key import ensure_encryption_key + + monkeypatch.delenv("ENCRYPTION_KEY", raising=False) + env_file = tmp_path / ".env" + env_file.write_text("OTHER=value\nUNRELATED=stuff\n") + with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"): + ensure_encryption_key(env_file) + # #endregion test_ensure_key_file_without_key_raises # #endregion Test.Encryption diff --git a/backend/tests/core/test_rate_limiter.py b/backend/tests/core/test_rate_limiter.py index d9d4101a..5dd65b0b 100644 --- a/backend/tests/core/test_rate_limiter.py +++ b/backend/tests/core/test_rate_limiter.py @@ -1,92 +1,227 @@ -# #region Test.RateLimiter [C:3] [TYPE Module] [SEMANTICS test,rate_limit,auth,throttle] -# @BRIEF Tests for core/rate_limiter.py — in-memory rate limiter, ban/window logic. +# #region Test.RateLimiter [C:3] [TYPE Module] [SEMANTICS test,rate_limiter,security,throttle] +# @BRIEF Verify RateLimiterModule contracts — ban/window logic, pruning, thread safety. # @RELATION BINDS_TO -> [RateLimiterModule] +# @TEST_EDGE: unknown_ip_not_banned -> is_banned returns False for unseen IP +# @TEST_EDGE: ban_triggers_at_limit -> MAX_ATTEMPTS+1 attempts triggers ban +# @TEST_EDGE: exactly_at_limit_no_ban -> exactly MAX_ATTEMPTS does NOT ban (code uses >) +# @TEST_EDGE: ban_expires -> ban auto-clears after BAN_DURATION seconds +# @TEST_EDGE: old_attempts_pruned -> attempts outside window are discarded +# @TEST_EDGE: success_clears_attempts -> record_success resets attempt counter +# @TEST_EDGE: success_does_not_clear_ban -> record_success does NOT lift active ban +# @TEST_EDGE: ban_clears_attempts -> after ban, attempt history is deleted from pathlib import Path import sys +import threading sys.path.insert(0, str(Path(__file__).parent.parent / "src")) -from unittest.mock import patch, MagicMock +from unittest.mock import patch import pytest +from src.core.rate_limiter import RateLimiter + + +# ── Constant overrides for fast, deterministic tests ── +# MAX_ATTEMPTS=2 → ban triggers on 3rd attempt (production code uses `>` not `>=`) +# ATTEMPT_WINDOW=3600 → 1-hour window (attempts stay valid within a single test) +# BAN_DURATION=3600 → 1-hour ban (testable via mocked monotonic clock) + +@pytest.fixture(autouse=True) +def _fast_constants(monkeypatch): + """Monkeypatch module constants to small values for fast testing.""" + monkeypatch.setattr("src.core.rate_limiter.MAX_ATTEMPTS", 2) + monkeypatch.setattr("src.core.rate_limiter.ATTEMPT_WINDOW", 3600) + monkeypatch.setattr("src.core.rate_limiter.BAN_DURATION", 3600) + + +@pytest.fixture +def limiter(): + """Fresh RateLimiter instance per test — no shared state.""" + return RateLimiter() + class TestRateLimiter: - """RateLimiter — in-memory sliding-window rate limiter.""" + """RateLimiter — in-memory sliding-window rate limiter keyed by IP.""" - @pytest.fixture - def limiter(self): - from src.core.rate_limiter import RateLimiter - return RateLimiter() - - def test_is_banned_initial_false(self, limiter): + # #region test_is_banned_unknown_ip [C:2] [TYPE Function] + # @BRIEF Unknown IP has no ban entry — is_banned returns False. + def test_is_banned_unknown_ip(self, limiter): assert limiter.is_banned("192.168.1.1") is False + # #endregion test_is_banned_unknown_ip + # #region test_is_banned_banned_ip [C:2] [TYPE Function] + # @BRIEF IP with 3 attempts (MAX_ATTEMPTS+1) is banned — is_banned returns True. + def test_is_banned_banned_ip(self, limiter): + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + + # Must check inside patch — real monotonic would exceed ban expiry + assert limiter.is_banned("10.0.0.1") is True + # #endregion test_is_banned_banned_ip + + # #region test_ban_expires_after_duration [C:2] [TYPE Function] + # @BRIEF Ban auto-clears once monotonic clock exceeds ban expiry. + def test_ban_expires_after_duration(self, limiter): + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + assert limiter.is_banned("10.0.0.1") is True + + # Advance clock past BAN_DURATION (3600s) + 1s margin + mock_time.monotonic.return_value = 4601.0 + assert limiter.is_banned("10.0.0.1") is False + # #endregion test_ban_expires_after_duration + + # #region test_record_attempt_under_limit [C:2] [TYPE Function] + # @BRIEF 2 attempts (exactly MAX_ATTEMPTS) does NOT trigger ban. def test_record_attempt_under_limit(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 - for i in range(5): # 5 attempts, well under MAX_ATTEMPTS=10 - limiter.record_attempt("192.168.1.1") - assert limiter.is_banned("192.168.1.1") is False + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") - def test_record_attempt_exceeds_limit(self, limiter): - from src.core.rate_limiter import MAX_ATTEMPTS + assert limiter.is_banned("10.0.0.1") is False + # #endregion test_record_attempt_under_limit + + # #region test_record_attempt_triggers_ban [C:2] [TYPE Function] + # @BRIEF 3rd attempt (MAX_ATTEMPTS+1) triggers ban — code uses `>` not `>=`. + def test_record_attempt_triggers_ban(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 - for i in range(MAX_ATTEMPTS + 1): - limiter.record_attempt("192.168.1.1") - assert limiter.is_banned("192.168.1.1") is True + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + assert limiter.is_banned("10.0.0.1") is False - def test_ban_expires(self, limiter): - from src.core.rate_limiter import MAX_ATTEMPTS, BAN_DURATION - with patch("src.core.rate_limiter.time") as mock_time: - mock_time.monotonic.return_value = 1000.0 - for i in range(MAX_ATTEMPTS + 1): - limiter.record_attempt("192.168.1.1") - assert limiter.is_banned("192.168.1.1") is True - - # Advance time past ban duration - mock_time.monotonic.return_value = 1000.0 + BAN_DURATION + 1 - assert limiter.is_banned("192.168.1.1") is False + limiter.record_attempt("10.0.0.1") + assert limiter.is_banned("10.0.0.1") is True + # #endregion test_record_attempt_triggers_ban + # #region test_old_attempts_pruned [C:2] [TYPE Function] + # @BRIEF Attempts older than ATTEMPT_WINDOW are discarded; fresh start after gap. def test_old_attempts_pruned(self, limiter): - from src.core.rate_limiter import MAX_ATTEMPTS with patch("src.core.rate_limiter.time") as mock_time: + # 2 attempts at t=1000 mock_time.monotonic.return_value = 1000.0 - for i in range(MAX_ATTEMPTS): - limiter.record_attempt("192.168.1.1") + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") - # Advance time past window but not to ban expiry - mock_time.monotonic.return_value = 1500.0 # 500s later, past 300s window - # Old attempts should be pruned. One new attempt should not trigger ban. - limiter.record_attempt("192.168.1.1") - assert limiter.is_banned("192.168.1.1") is False + # Advance past ATTEMPT_WINDOW (3600s) — old attempts expire + mock_time.monotonic.return_value = 5000.0 + limiter.record_attempt("10.0.0.1") + # Only 1 attempt in window — not banned + assert limiter.is_banned("10.0.0.1") is False + # #endregion test_old_attempts_pruned + + # #region test_record_success_clears_attempts [C:2] [TYPE Function] + # @BRIEF record_success resets attempt counter; subsequent attempts start fresh. def test_record_success_clears_attempts(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 - for i in range(5): - limiter.record_attempt("192.168.1.1") - limiter.record_success("192.168.1.1") - assert limiter.is_banned("192.168.1.1") is False + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + limiter.record_success("10.0.0.1") + # 2 more attempts after success — counter was reset, still under limit + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + assert limiter.is_banned("10.0.0.1") is False + # #endregion test_record_success_clears_attempts + + # #region test_record_success_does_not_clear_ban [C:2] [TYPE Function] + # @BRIEF record_success clears attempts but does NOT lift an active ban. + def test_record_success_does_not_clear_ban(self, limiter): + with patch("src.core.rate_limiter.time") as mock_time: + mock_time.monotonic.return_value = 1000.0 + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + assert limiter.is_banned("10.0.0.1") is True + + # Success clears attempts but ban remains + limiter.record_success("10.0.0.1") + assert limiter.is_banned("10.0.0.1") is True + # #endregion test_record_success_does_not_clear_ban + + # #region test_different_ips_independent [C:2] [TYPE Function] + # @BRIEF Ban on one IP does not affect other IPs. def test_different_ips_independent(self, limiter): with patch("src.core.rate_limiter.time") as mock_time: mock_time.monotonic.return_value = 1000.0 - for i in range(15): # exceeds limit for ip1 - limiter.record_attempt("192.168.1.1") - assert limiter.is_banned("192.168.1.1") is True - # Different IP should not be affected + for _ in range(3): + limiter.record_attempt("10.0.0.1") + + assert limiter.is_banned("10.0.0.1") is True + assert limiter.is_banned("192.168.1.1") is False + # #endregion test_different_ips_independent + + # #region test_ban_clears_attempts [C:2] [TYPE Function] + # @BRIEF After ban, attempt history is deleted; post-expiry attempts start fresh. + def test_ban_clears_attempts(self, limiter): + with patch("src.core.rate_limiter.time") as mock_time: + # Trigger ban at t=1000 + mock_time.monotonic.return_value = 1000.0 + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + assert limiter.is_banned("10.0.0.1") is True + + # Advance past ban expiry + mock_time.monotonic.return_value = 4601.0 assert limiter.is_banned("10.0.0.1") is False + # 2 fresh attempts — if old attempts weren't cleared, these would + # stack on top of 3 old ones (5 > 2) and re-trigger ban immediately. + limiter.record_attempt("10.0.0.1") + limiter.record_attempt("10.0.0.1") + assert limiter.is_banned("10.0.0.1") is False + # #endregion test_ban_clears_attempts + + # #region test_thread_safety_basic [C:2] [TYPE Function] + # @BRIEF Concurrent record_attempt calls from multiple threads do not raise. + def test_thread_safety_basic(self, limiter): + errors = [] + + def worker(ip, count): + try: + for _ in range(count): + limiter.record_attempt(ip) + limiter.is_banned(ip) + except Exception as exc: + errors.append(exc) + + threads = [ + threading.Thread(target=worker, args=("10.0.0.1", 50)), + threading.Thread(target=worker, args=("10.0.0.2", 50)), + threading.Thread(target=worker, args=("10.0.0.3", 50)), + ] + for t in threads: + t.start() + for t in threads: + t.join(timeout=5) + + assert errors == [] + # #endregion test_thread_safety_basic + class TestRateLimiterSingleton: - """rate_limiter singleton is pre-created.""" + """rate_limiter singleton is pre-created at module level.""" + # #region test_singleton_exists [C:2] [TYPE Function] + # @BRIEF Module-level singleton exists and exposes the expected interface. def test_singleton_exists(self): from src.core.rate_limiter import rate_limiter + assert rate_limiter is not None assert hasattr(rate_limiter, "is_banned") assert hasattr(rate_limiter, "record_attempt") assert hasattr(rate_limiter, "record_success") + # #endregion test_singleton_exists + # #endregion Test.RateLimiter diff --git a/backend/tests/core/test_scheduler.py b/backend/tests/core/test_scheduler.py new file mode 100644 index 00000000..0f8395d9 --- /dev/null +++ b/backend/tests/core/test_scheduler.py @@ -0,0 +1,910 @@ +# #region Test.Scheduler [C:3] [TYPE Module] [SEMANTICS test,scheduler,core] +# @BRIEF Unit tests for SchedulerModule — SchedulerService lifecycle, job management, and ThrottledSchedulerConfigurator. +# @RELATION BINDS_TO -> [SchedulerModule] +# @TEST_EDGE: empty_queue -> _trigger_backup with no active tasks creates a new task +# @TEST_EDGE: duplicate_backup -> _trigger_backup skips when backup already running +# @TEST_EDGE: invalid_cron -> add_backup_job handles invalid cron expression gracefully +# @TEST_EDGE: translation_load_failure -> load_schedules continues when translation DB query fails +# @TEST_EDGE: validation_load_failure -> load_schedules continues when validation DB query fails +# @TEST_EDGE: policy_not_found -> reload_validation_policy removes job when policy missing +# @TEST_EDGE: policy_inactive -> reload_validation_policy removes job when policy inactive +# @TEST_EDGE: policy_no_schedule -> reload_validation_policy skips when no schedule_days/window +# @TEST_EDGE: zero_window -> calculate_schedule returns start_dt repeated for zero-length window +# @TEST_EDGE: midnight_crossing -> calculate_schedule handles window crossing midnight +# @TEST_EDGE: single_dashboard -> calculate_schedule returns single start_dt for one dashboard +import asyncio +from datetime import date, datetime, time, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock + +import pytest + + +# ── Helpers (C1 — anchor pair only) ── + +# #region _build_config_manager [C:1] [TYPE Function] +def _build_config_manager(environments=None, app_timezone="Europe/Moscow"): + """Build a mock ConfigManager with configurable environments.""" + cm = MagicMock() + config = MagicMock() + settings = MagicMock() + settings.app_timezone = app_timezone + config.settings = settings + config.environments = environments or [] + cm.get_config.return_value = config + return cm +# #endregion _build_config_manager + +# #region _build_env [C:1] [TYPE Function] +def _build_env(env_id="env-1", cron="0 2 * * *", enabled=True): + """Build a mock Environment with backup_schedule.""" + env = MagicMock() + env.id = env_id + sched = MagicMock() + sched.enabled = enabled + sched.cron_expression = cron + env.backup_schedule = sched + return env +# #endregion _build_env + +# #region _build_task [C:1] [TYPE Function] +def _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-1"): + """Build a mock TaskRecord.""" + task = MagicMock() + task.plugin_id = plugin_id + task.status = status + task.params = {"environment_id": environment_id} + return task +# #endregion _build_task + +# #region _build_policy [C:1] [TYPE Function] +def _build_policy(policy_id="pol-1", is_active=True, schedule_days=None, window_start=None, window_end=None, dashboard_ids=None, environment_id="env-1", provider_id="prov-1"): + """Build a mock ValidationPolicy.""" + policy = MagicMock() + policy.id = policy_id + policy.is_active = is_active + policy.schedule_days = schedule_days + policy.window_start = window_start + policy.window_end = window_end + policy.dashboard_ids = dashboard_ids + policy.environment_id = environment_id + policy.provider_id = provider_id + return policy +# #endregion _build_policy + + +# ── Fixtures ── + +@pytest.fixture +def mock_task_manager(): + tm = MagicMock() + tm.get_tasks.return_value = [] + tm.create_task = AsyncMock(return_value=MagicMock()) + return tm + + +@pytest.fixture +def mock_config_manager(): + return _build_config_manager() + + +@pytest.fixture +def mock_scheduler(): + """Patch BackgroundScheduler so no real threads are started.""" + with patch("src.core.scheduler.BackgroundScheduler") as cls: + sched_instance = MagicMock() + sched_instance.running = False + cls.return_value = sched_instance + yield sched_instance + + +@pytest.fixture +def service(mock_task_manager, mock_config_manager, mock_scheduler): + """Construct a SchedulerService with all external deps mocked.""" + # Create a fresh event loop to avoid "no current event loop" errors + # when the scheduler test runs after async tests in the same session. + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.asyncio.get_event_loop", return_value=loop): + from src.core.scheduler import SchedulerService + svc = SchedulerService(mock_task_manager, mock_config_manager) + yield svc + loop.close() + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.__init__ (lines 28-32) +# ══════════════════════════════════════════════════════════════ + +# #region test_init_stores_deps [C:2] [TYPE Function] +# @BRIEF __init__ stores task_manager, config_manager, creates scheduler and loop. +def test_init_stores_deps(service, mock_task_manager, mock_config_manager, mock_scheduler): + assert service.task_manager is mock_task_manager + assert service.config_manager is mock_config_manager + assert service.scheduler is mock_scheduler + assert service.loop is not None +# #endregion test_init_stores_deps + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.start (lines 40-44) +# ══════════════════════════════════════════════════════════════ + +# #region test_start_when_not_running [C:2] [TYPE Function] +# @BRIEF start() starts scheduler and loads schedules when not already running. +def test_start_when_not_running(service, mock_scheduler): + mock_scheduler.running = False + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch.object(service, "load_schedules") as mock_load: + service.start() + mock_scheduler.start.assert_called_once() + mock_load.assert_called_once() +# #endregion test_start_when_not_running + +# #region test_start_when_already_running [C:2] [TYPE Function] +# @BRIEF start() is a no-op when scheduler is already running. +def test_start_when_already_running(service, mock_scheduler): + mock_scheduler.running = True + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch.object(service, "load_schedules") as mock_load: + service.start() + mock_scheduler.start.assert_not_called() + mock_load.assert_not_called() +# #endregion test_start_when_already_running + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.stop (lines 52-55) +# ══════════════════════════════════════════════════════════════ + +# #region test_stop_when_running [C:2] [TYPE Function] +# @BRIEF stop() shuts down scheduler when it is running. +def test_stop_when_running(service, mock_scheduler): + mock_scheduler.running = True + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + service.stop() + mock_scheduler.shutdown.assert_called_once() +# #endregion test_stop_when_running + +# #region test_stop_when_not_running [C:2] [TYPE Function] +# @BRIEF stop() is a no-op when scheduler is not running. +def test_stop_when_not_running(service, mock_scheduler): + mock_scheduler.running = False + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + service.stop() + mock_scheduler.shutdown.assert_not_called() +# #endregion test_stop_when_not_running + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.load_schedules (lines 63-130) +# ══════════════════════════════════════════════════════════════ + +# #region test_load_schedules_backup_jobs [C:2] [TYPE Function] +# @BRIEF load_schedules registers backup jobs for enabled environments. +def test_load_schedules_backup_jobs(service, mock_scheduler, mock_config_manager): + env1 = _build_env("env-1", "0 2 * * *", enabled=True) + env2 = _build_env("env-2", "0 3 * * *", enabled=False) + cm = _build_config_manager(environments=[env1, env2]) + service.config_manager = cm + + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + # No translation schedules, no validation policies + db_mock.query.return_value.filter.return_value.all.return_value = [] + with patch.object(service, "add_backup_job") as mock_add: + service.load_schedules() + # Only env-1 has enabled schedule + mock_add.assert_called_once_with("env-1", "0 2 * * *") +# #endregion test_load_schedules_backup_jobs + +# #region test_load_schedules_translation_exception [C:2] [TYPE Function] +# @BRIEF load_schedules catches exception when loading translation schedules (lines 93-94). +def test_load_schedules_translation_exception(service, mock_config_manager): + cm = _build_config_manager(environments=[]) + service.config_manager = cm + + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal", side_effect=RuntimeError("DB connection lost")): + # Should not raise — exception is caught at line 93 + service.load_schedules() +# #endregion test_load_schedules_translation_exception + +# #region test_load_schedules_validation_exception [C:2] [TYPE Function] +# @BRIEF load_schedules catches exception when loading validation schedules (lines 128-129). +def test_load_schedules_validation_exception(service, mock_config_manager): + cm = _build_config_manager(environments=[]) + service.config_manager = cm + + call_count = 0 + original_session_local = None + + def session_side_effect(): + nonlocal call_count + call_count += 1 + if call_count == 1: + # First call (translation) — return empty mock + db = MagicMock() + db.query.return_value.filter.return_value.all.return_value = [] + return db + else: + # Second call (validation) — raise + raise RuntimeError("Validation DB error") + + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal", side_effect=session_side_effect): + # Should not raise — exception caught at line 128 + service.load_schedules() +# #endregion test_load_schedules_validation_exception + +# #region test_load_schedules_translation_schedules_success [C:2] [TYPE Function] +# @BRIEF load_schedules registers active translation schedules from DB (lines 82, 90). +def test_load_schedules_translation_schedules_success(service, mock_config_manager): + cm = _build_config_manager(environments=[], app_timezone="UTC") + service.config_manager = cm + + # Build a mock TranslationSchedule row + ts = MagicMock() + ts.id = "ts-1" + ts.job_id = "job-ts-1" + ts.cron_expression = "*/10 * * * *" + ts.timezone = "US/Pacific" + ts.execution_mode = "incremental" + + call_count = 0 + + def session_factory(): + nonlocal call_count + call_count += 1 + db = MagicMock() + if call_count == 1: + # Translation query — return one active schedule + db.query.return_value.filter.return_value.all.return_value = [ts] + else: + # Validation query — empty + db.query.return_value.filter.return_value.all.return_value = [] + return db + + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal", side_effect=session_factory): + with patch.object(service, "add_translation_job") as mock_add_t: + with patch.object(service, "add_validation_job") as mock_add_v: + service.load_schedules() + mock_add_t.assert_called_once_with( + schedule_id="ts-1", + job_id="job-ts-1", + cron_expression="*/10 * * * *", + timezone="US/Pacific", + execution_mode="incremental", + ) + mock_add_v.assert_not_called() +# #endregion test_load_schedules_translation_schedules_success + +# #region test_load_schedules_validation_policy_skip_empty_days [C:2] [TYPE Function] +# @BRIEF load_schedules skips validation policies with empty schedule_days (line 112). +def test_load_schedules_validation_policy_skip_empty_days(service, mock_config_manager): + cm = _build_config_manager(environments=[], app_timezone="UTC") + service.config_manager = cm + + policy_no_days = _build_policy("pol-1", is_active=True, schedule_days=[], window_start=time(10, 0)) + policy_none_days = _build_policy("pol-2", is_active=True, schedule_days=None, window_start=time(10, 0)) + + call_count = 0 + + def session_factory(): + nonlocal call_count + call_count += 1 + db = MagicMock() + if call_count == 1: + # Translation query — empty + db.query.return_value.filter.return_value.all.return_value = [] + else: + # Validation query — policies with empty/None schedule_days + db.query.return_value.filter.return_value.all.return_value = [policy_no_days, policy_none_days] + return db + + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal", side_effect=session_factory): + with patch.object(service, "add_validation_job") as mock_add: + service.load_schedules() + # Neither policy should be registered (both have empty/None schedule_days) + mock_add.assert_not_called() +# #endregion test_load_schedules_validation_policy_skip_empty_days + +# #region test_load_schedules_validation_policy_skip_no_window [C:2] [TYPE Function] +# @BRIEF load_schedules skips validation policies with None window_start (line 115). +def test_load_schedules_validation_policy_skip_no_window(service, mock_config_manager): + cm = _build_config_manager(environments=[], app_timezone="UTC") + service.config_manager = cm + + policy_no_ws = _build_policy("pol-3", is_active=True, schedule_days=[1, 3], window_start=None) + + call_count = 0 + + def session_factory(): + nonlocal call_count + call_count += 1 + db = MagicMock() + if call_count == 1: + db.query.return_value.filter.return_value.all.return_value = [] + else: + db.query.return_value.filter.return_value.all.return_value = [policy_no_ws] + return db + + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal", side_effect=session_factory): + with patch.object(service, "add_validation_job") as mock_add: + service.load_schedules() + mock_add.assert_not_called() +# #endregion test_load_schedules_validation_policy_skip_no_window + +# #region test_load_schedules_validation_policy_success [C:2] [TYPE Function] +# @BRIEF load_schedules registers valid validation policies with cron expression. +def test_load_schedules_validation_policy_success(service, mock_config_manager): + cm = _build_config_manager(environments=[], app_timezone="UTC") + service.config_manager = cm + + ws = time(10, 30) + policy = _build_policy("pol-ok", is_active=True, schedule_days=[1, 3, 5], window_start=ws) + + call_count = 0 + + def session_factory(): + nonlocal call_count + call_count += 1 + db = MagicMock() + if call_count == 1: + db.query.return_value.filter.return_value.all.return_value = [] + else: + db.query.return_value.filter.return_value.all.return_value = [policy] + return db + + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal", side_effect=session_factory): + with patch.object(service, "add_validation_job") as mock_add: + service.load_schedules() + # cron: minute=30 hour=10 * * 1,3,5 + mock_add.assert_called_once_with(policy_id="pol-ok", cron_expression="30 10 * * 1,3,5", app_timezone="UTC") +# #endregion test_load_schedules_validation_policy_success + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.add_backup_job (lines 139-157) +# ══════════════════════════════════════════════════════════════ + +# #region test_add_backup_job_success [C:2] [TYPE Function] +# @BRIEF add_backup_job registers a job with APScheduler. +def test_add_backup_job_success(service, mock_scheduler): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.CronTrigger") as mock_trigger: + mock_trigger.from_crontab.return_value = "trigger_obj" + service.add_backup_job("env-1", "0 2 * * *") + mock_scheduler.add_job.assert_called_once() + call_kwargs = mock_scheduler.add_job.call_args + assert call_kwargs.kwargs["id"] == "backup_env-1" + assert call_kwargs.kwargs["replace_existing"] is True +# #endregion test_add_backup_job_success + +# #region test_add_backup_job_invalid_cron [C:2] [TYPE Function] +# @BRIEF add_backup_job handles invalid cron expression without raising (lines 156-157). +def test_add_backup_job_invalid_cron(service, mock_scheduler): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.CronTrigger") as mock_trigger: + mock_trigger.from_crontab.side_effect = ValueError("Invalid cron") + # Should not raise + service.add_backup_job("env-bad", "invalid_cron") + mock_scheduler.add_job.assert_not_called() +# #endregion test_add_backup_job_invalid_cron + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.add_translation_job (lines 167-193) +# ══════════════════════════════════════════════════════════════ + +# #region test_add_translation_job_success [C:2] [TYPE Function] +# @BRIEF add_translation_job registers a translation job with APScheduler. +def test_add_translation_job_success(service, mock_scheduler): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.CronTrigger") as mock_trigger: + mock_trigger.from_crontab.return_value = "trigger_obj" + service.add_translation_job("sched-1", "job-1", "*/5 * * * *", "US/Eastern", "incremental") + mock_scheduler.add_job.assert_called_once() + call_kwargs = mock_scheduler.add_job.call_args + assert call_kwargs.kwargs["id"] == "translate_sched-1" +# #endregion test_add_translation_job_success + +# #region test_add_translation_job_exception [C:2] [TYPE Function] +# @BRIEF add_translation_job handles registration failure gracefully (lines 189-190). +def test_add_translation_job_exception(service, mock_scheduler): + mock_scheduler.add_job.side_effect = RuntimeError("APScheduler failure") + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.CronTrigger") as mock_trigger: + mock_trigger.from_crontab.return_value = "trigger_obj" + # Should not raise + service.add_translation_job("sched-2", "job-2", "0 0 * * *") +# #endregion test_add_translation_job_exception + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.remove_translation_job +# ══════════════════════════════════════════════════════════════ + +# #region test_remove_translation_job_exists [C:2] [TYPE Function] +# @BRIEF remove_translation_job removes an existing job. +def test_remove_translation_job_exists(service, mock_scheduler): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + service.remove_translation_job("sched-1") + mock_scheduler.remove_job.assert_called_once_with("translate_sched-1") +# #endregion test_remove_translation_job_exists + +# #region test_remove_translation_job_not_found [C:2] [TYPE Function] +# @BRIEF remove_translation_job silently handles missing job. +def test_remove_translation_job_not_found(service, mock_scheduler): + mock_scheduler.remove_job.side_effect = Exception("Job not found") + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + # Should not raise + service.remove_translation_job("sched-missing") +# #endregion test_remove_translation_job_not_found + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService._trigger_backup (lines 218-241) +# ══════════════════════════════════════════════════════════════ + +# #region test_trigger_backup_no_active_tasks [C:2] [TYPE Function] +# @BRIEF _trigger_backup creates a task when no backup is running (lines 219-236). +def test_trigger_backup_no_active_tasks(service, mock_task_manager): + mock_task_manager.get_tasks.return_value = [] + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run: + service._trigger_backup("env-1") + # create_task is called and its coroutine passed to run_coroutine_threadsafe + mock_run.assert_called_once() + mock_task_manager.create_task.assert_called_once_with( + "superset-backup", {"environment_id": "env-1"} + ) +# #endregion test_trigger_backup_no_active_tasks + +# #region test_trigger_backup_already_running [C:2] [TYPE Function] +# @BRIEF _trigger_backup skips when a backup is already running for the environment. +def test_trigger_backup_already_running(service, mock_task_manager): + running_task = _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-1") + mock_task_manager.get_tasks.return_value = [running_task] + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run: + service._trigger_backup("env-1") + mock_run.assert_not_called() +# #endregion test_trigger_backup_already_running + +# #region test_trigger_backup_pending_status [C:2] [TYPE Function] +# @BRIEF _trigger_backup skips when a backup is in PENDING status for the environment. +def test_trigger_backup_pending_status(service, mock_task_manager): + pending_task = _build_task(plugin_id="superset-backup", status="PENDING", environment_id="env-1") + mock_task_manager.get_tasks.return_value = [pending_task] + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run: + service._trigger_backup("env-1") + mock_run.assert_not_called() +# #endregion test_trigger_backup_pending_status + +# #region test_trigger_backup_different_env [C:2] [TYPE Function] +# @BRIEF _trigger_backup proceeds when running backup is for a different environment. +def test_trigger_backup_different_env(service, mock_task_manager): + other_task = _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-2") + mock_task_manager.get_tasks.return_value = [other_task] + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run: + service._trigger_backup("env-1") + mock_run.assert_called_once() +# #endregion test_trigger_backup_different_env + +# #region test_trigger_backup_other_plugin [C:2] [TYPE Function] +# @BRIEF _trigger_backup proceeds when running task is a different plugin. +def test_trigger_backup_other_plugin(service, mock_task_manager): + other_plugin_task = _build_task(plugin_id="other-plugin", status="RUNNING", environment_id="env-1") + mock_task_manager.get_tasks.return_value = [other_plugin_task] + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run: + service._trigger_backup("env-1") + mock_run.assert_called_once() +# #endregion test_trigger_backup_other_plugin + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.add_validation_job +# ══════════════════════════════════════════════════════════════ + +# #region test_add_validation_job_success [C:2] [TYPE Function] +# @BRIEF add_validation_job registers a validation job with APScheduler. +def test_add_validation_job_success(service, mock_scheduler): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.CronTrigger") as mock_trigger: + mock_trigger.from_crontab.return_value = "trigger_obj" + service.add_validation_job("pol-1", "30 10 * * 1,3", app_timezone="UTC") + mock_scheduler.add_job.assert_called_once() + call_kwargs = mock_scheduler.add_job.call_args + assert call_kwargs.kwargs["id"] == "validation_pol-1" +# #endregion test_add_validation_job_success + +# #region test_add_validation_job_exception [C:2] [TYPE Function] +# @BRIEF add_validation_job handles registration failure gracefully. +def test_add_validation_job_exception(service, mock_scheduler): + mock_scheduler.add_job.side_effect = RuntimeError("Scheduler error") + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.CronTrigger") as mock_trigger: + mock_trigger.from_crontab.return_value = "trigger_obj" + # Should not raise + service.add_validation_job("pol-bad", "30 10 * * 1") +# #endregion test_add_validation_job_exception + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.remove_validation_job +# ══════════════════════════════════════════════════════════════ + +# #region test_remove_validation_job_exists [C:2] [TYPE Function] +# @BRIEF remove_validation_job removes an existing job. +def test_remove_validation_job_exists(service, mock_scheduler): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + service.remove_validation_job("pol-1") + mock_scheduler.remove_job.assert_called_once_with("validation_pol-1") +# #endregion test_remove_validation_job_exists + +# #region test_remove_validation_job_not_found [C:2] [TYPE Function] +# @BRIEF remove_validation_job silently handles missing job. +def test_remove_validation_job_not_found(service, mock_scheduler): + mock_scheduler.remove_job.side_effect = Exception("Not found") + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + # Should not raise + service.remove_validation_job("pol-missing") +# #endregion test_remove_validation_job_not_found + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService.reload_validation_policy (lines 300-331) +# ══════════════════════════════════════════════════════════════ + +# #region test_reload_validation_policy_not_found [C:2] [TYPE Function] +# @BRIEF reload_validation_policy removes job when policy not found in DB (lines 315-316). +def test_reload_validation_policy_not_found(service, mock_scheduler): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + db_mock.query.return_value.filter.return_value.first.return_value = None + with patch.object(service, "remove_validation_job") as mock_remove: + with patch.object(service, "add_validation_job") as mock_add: + service.reload_validation_policy("pol-missing") + mock_remove.assert_called_once_with("pol-missing") + mock_add.assert_not_called() +# #endregion test_reload_validation_policy_not_found + +# #region test_reload_validation_policy_inactive [C:2] [TYPE Function] +# @BRIEF reload_validation_policy removes job when policy is inactive (lines 319-320). +def test_reload_validation_policy_inactive(service, mock_scheduler): + policy = _build_policy("pol-inactive", is_active=False) + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + db_mock.query.return_value.filter.return_value.first.return_value = policy + with patch.object(service, "remove_validation_job") as mock_remove: + with patch.object(service, "add_validation_job") as mock_add: + service.reload_validation_policy("pol-inactive") + mock_remove.assert_called_once_with("pol-inactive") + mock_add.assert_not_called() +# #endregion test_reload_validation_policy_inactive + +# #region test_reload_validation_policy_no_schedule [C:2] [TYPE Function] +# @BRIEF reload_validation_policy skips registration when no schedule_days or window_start (lines 327-328 area). +def test_reload_validation_policy_no_schedule(service, mock_scheduler): + policy = _build_policy("pol-nosched", is_active=True, schedule_days=[], window_start=None) + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + db_mock.query.return_value.filter.return_value.first.return_value = policy + with patch.object(service, "remove_validation_job") as mock_remove: + with patch.object(service, "add_validation_job") as mock_add: + service.reload_validation_policy("pol-nosched") + mock_remove.assert_called_once_with("pol-nosched") + mock_add.assert_not_called() +# #endregion test_reload_validation_policy_no_schedule + +# #region test_reload_validation_policy_success [C:2] [TYPE Function] +# @BRIEF reload_validation_policy removes old and registers new job for active policy with schedule. +def test_reload_validation_policy_success(service, mock_scheduler): + ws = time(14, 0) + policy = _build_policy("pol-ok", is_active=True, schedule_days=[0, 2, 4], window_start=ws) + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + db_mock.query.return_value.filter.return_value.first.return_value = policy + with patch.object(service, "remove_validation_job") as mock_remove: + with patch.object(service, "add_validation_job") as mock_add: + service.reload_validation_policy("pol-ok", app_timezone="UTC") + mock_remove.assert_called_once_with("pol-ok") + # cron: minute=0 hour=14 * * 0,2,4 + mock_add.assert_called_once_with("pol-ok", "0 14 * * 0,2,4", app_timezone="UTC") +# #endregion test_reload_validation_policy_success + +# #region test_reload_validation_policy_exception [C:2] [TYPE Function] +# @BRIEF reload_validation_policy handles DB exception gracefully (lines 327-328). +def test_reload_validation_policy_exception(service, mock_scheduler): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal", side_effect=RuntimeError("DB down")): + with patch.object(service, "remove_validation_job"): + # Should not raise + service.reload_validation_policy("pol-err") +# #endregion test_reload_validation_policy_exception + + +# ══════════════════════════════════════════════════════════════ +# SchedulerService._trigger_validation (lines 338-391) +# ══════════════════════════════════════════════════════════════ + +# #region test_trigger_validation_policy_not_found [C:2] [TYPE Function] +# @BRIEF _trigger_validation returns early when policy not found in DB. +def test_trigger_validation_policy_not_found(service, mock_task_manager): + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + db_mock.query.return_value.filter.return_value.first.return_value = None + # Should not raise + service._trigger_validation("pol-missing") + db_mock.close.assert_called_once() +# #endregion test_trigger_validation_policy_not_found + +# #region test_trigger_validation_policy_inactive [C:2] [TYPE Function] +# @BRIEF _trigger_validation returns early when policy is inactive. +def test_trigger_validation_policy_inactive(service, mock_task_manager): + policy = _build_policy("pol-inactive", is_active=False) + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + db_mock.query.return_value.filter.return_value.first.return_value = policy + service._trigger_validation("pol-inactive") + mock_task_manager.create_task.assert_not_called() +# #endregion test_trigger_validation_policy_inactive + +# #region test_trigger_validation_no_dashboards [C:2] [TYPE Function] +# @BRIEF _trigger_validation returns early when policy has no dashboard_ids. +def test_trigger_validation_no_dashboards(service, mock_task_manager): + policy = _build_policy("pol-nodash", is_active=True, dashboard_ids=[], window_start=time(10, 0), window_end=time(11, 0)) + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + db_mock.query.return_value.filter.return_value.first.return_value = policy + service._trigger_validation("pol-nodash") + mock_task_manager.create_task.assert_not_called() +# #endregion test_trigger_validation_no_dashboards + +# #region test_trigger_validation_success [C:2] [TYPE Function] +# @BRIEF _trigger_validation spawns tasks for each dashboard in the policy. +def test_trigger_validation_success(service, mock_task_manager): + dash_ids = ["dash-1", "dash-2"] + policy = _build_policy( + "pol-ok", is_active=True, dashboard_ids=dash_ids, + window_start=time(10, 0), window_end=time(11, 0), + environment_id="env-1", provider_id="prov-1", + ) + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + db_mock.query.return_value.filter.return_value.first.return_value = policy + with patch("src.core.scheduler.ThrottledSchedulerConfigurator") as mock_cfg: + mock_cfg.calculate_schedule.return_value = [ + datetime(2026, 6, 11, 10, 0), + datetime(2026, 6, 11, 11, 0), + ] + with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run: + service._trigger_validation("pol-ok") + # Two dashboards → two run_coroutine_threadsafe calls + assert mock_run.call_count == 2 +# #endregion test_trigger_validation_success + +# #region test_trigger_validation_exception [C:2] [TYPE Function] +# @BRIEF _trigger_validation handles exception gracefully (lines 385-386). +def test_trigger_validation_exception(service, mock_task_manager): + with patch("src.core.scheduler.seed_trace_id"): + with patch("src.core.scheduler.belief_scope") as mock_bs: + mock_bs.return_value.__enter__ = MagicMock() + mock_bs.return_value.__exit__ = MagicMock(return_value=False) + with patch("src.core.scheduler.SessionLocal") as mock_session: + db_mock = MagicMock() + mock_session.return_value = db_mock + # Raise inside the try block (query, not SessionLocal itself) + db_mock.query.side_effect = RuntimeError("DB crash") + # Should not raise + service._trigger_validation("pol-err") + db_mock.close.assert_called_once() +# #endregion test_trigger_validation_exception + + +# ══════════════════════════════════════════════════════════════ +# ThrottledSchedulerConfigurator.calculate_schedule (lines 410-445) +# ══════════════════════════════════════════════════════════════ + +# #region test_calculate_schedule_empty [C:2] [TYPE Function] +# @BRIEF calculate_schedule returns empty list for empty dashboard_ids (line 417). +def test_calculate_schedule_empty(): + from src.core.scheduler import ThrottledSchedulerConfigurator + result = ThrottledSchedulerConfigurator.calculate_schedule( + window_start=time(10, 0), + window_end=time(11, 0), + dashboard_ids=[], + current_date=date(2026, 6, 11), + ) + assert result == [] +# #endregion test_calculate_schedule_empty + +# #region test_calculate_schedule_single_dashboard [C:2] [TYPE Function] +# @BRIEF calculate_schedule returns single start_dt for one dashboard (line 434). +def test_calculate_schedule_single_dashboard(): + from src.core.scheduler import ThrottledSchedulerConfigurator + result = ThrottledSchedulerConfigurator.calculate_schedule( + window_start=time(10, 0), + window_end=time(11, 0), + dashboard_ids=["dash-1"], + current_date=date(2026, 6, 11), + ) + assert len(result) == 1 + assert result[0] == datetime(2026, 6, 11, 10, 0) +# #endregion test_calculate_schedule_single_dashboard + +# #region test_calculate_schedule_multiple [C:2] [TYPE Function] +# @BRIEF calculate_schedule distributes tasks evenly across the window. +def test_calculate_schedule_multiple(): + from src.core.scheduler import ThrottledSchedulerConfigurator + result = ThrottledSchedulerConfigurator.calculate_schedule( + window_start=time(10, 0), + window_end=time(11, 0), + dashboard_ids=["d1", "d2", "d3"], + current_date=date(2026, 6, 11), + ) + assert len(result) == 3 + # First at 10:00, last at 11:00, middle at 10:30 + assert result[0] == datetime(2026, 6, 11, 10, 0) + assert result[1] == datetime(2026, 6, 11, 10, 30) + assert result[2] == datetime(2026, 6, 11, 11, 0) +# #endregion test_calculate_schedule_multiple + +# #region test_calculate_schedule_midnight_crossing [C:2] [TYPE Function] +# @BRIEF calculate_schedule handles window crossing midnight (line 422). +def test_calculate_schedule_midnight_crossing(): + from src.core.scheduler import ThrottledSchedulerConfigurator + result = ThrottledSchedulerConfigurator.calculate_schedule( + window_start=time(23, 0), + window_end=time(1, 0), + dashboard_ids=["d1", "d2", "d3"], + current_date=date(2026, 6, 11), + ) + assert len(result) == 3 + # Window: 23:00 → 01:00 next day = 2 hours = 7200s + # Interval: 7200 / 2 = 3600s = 1h + assert result[0] == datetime(2026, 6, 11, 23, 0) + assert result[1] == datetime(2026, 6, 12, 0, 0) + assert result[2] == datetime(2026, 6, 12, 1, 0) +# #endregion test_calculate_schedule_midnight_crossing + +# #region test_calculate_schedule_zero_window [C:2] [TYPE Function] +# @BRIEF calculate_schedule returns start_dt repeated for zero-length window (lines 426-429). +def test_calculate_schedule_zero_window(): + from src.core.scheduler import ThrottledSchedulerConfigurator + result = ThrottledSchedulerConfigurator.calculate_schedule( + window_start=time(10, 0), + window_end=time(10, 0), + dashboard_ids=["d1", "d2", "d3"], + current_date=date(2026, 6, 11), + ) + assert len(result) == 3 + expected = datetime(2026, 6, 11, 10, 0) + assert all(t == expected for t in result) +# #endregion test_calculate_schedule_zero_window + +# #region test_calculate_schedule_small_window_warning [C:2] [TYPE Function] +# @BRIEF calculate_schedule warns when interval < 1s (lines 439+). +def test_calculate_schedule_small_window_warning(): + from src.core.scheduler import ThrottledSchedulerConfigurator + # 10 dashboards in 5 seconds → interval = 5/9 ≈ 0.56s < 1s + result = ThrottledSchedulerConfigurator.calculate_schedule( + window_start=time(10, 0, 0), + window_end=time(10, 0, 5), + dashboard_ids=[f"d{i}" for i in range(10)], + current_date=date(2026, 6, 11), + ) + assert len(result) == 10 + # First should be at start, last should be within the window + assert result[0] == datetime(2026, 6, 11, 10, 0, 0) +# #endregion test_calculate_schedule_small_window_warning + +# #endregion Test.Scheduler diff --git a/backend/tests/core/test_timezone.py b/backend/tests/core/test_timezone.py new file mode 100644 index 00000000..b8c7f143 --- /dev/null +++ b/backend/tests/core/test_timezone.py @@ -0,0 +1,176 @@ +# #region Test.AppTimezone [C:3] [TYPE Module] [SEMANTICS test,timezone] +# @BRIEF Unit tests for AppTimezone module — timezone utilities, caching, and localization. +# @RELATION BINDS_TO -> [AppTimezone] +# @TEST_EDGE: invalid_timezone -> validate_timezone returns False for unknown names +# @TEST_EDGE: none_datetime -> localize returns None for None input +# @TEST_EDGE: cache_invalidation -> invalidate clears cache, next call re-reads env +import pytest +from datetime import datetime +from zoneinfo import ZoneInfo + +from src.core.timezone import ( + _get_default_tz_name, + get_app_timezone, + invalidate_timezone_cache, + validate_timezone, + localize, + now, + format_timezone_offset, +) + + +@pytest.fixture(autouse=True) +def _clean_cache(): + """Ensure cache is clean before and after each test.""" + invalidate_timezone_cache() + yield + invalidate_timezone_cache() + + +# #region test_get_default_tz_name_default [C:2] [TYPE Function] +# @BRIEF Without APP_TIMEZONE env var, returns "Europe/Moscow". +def test_get_default_tz_name_default(monkeypatch): + monkeypatch.delenv("APP_TIMEZONE", raising=False) + assert _get_default_tz_name() == "Europe/Moscow" +# #endregion test_get_default_tz_name_default + + +# #region test_get_default_tz_name_custom [C:2] [TYPE Function] +# @BRIEF With APP_TIMEZONE set, returns the custom value. +def test_get_default_tz_name_custom(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "US/Eastern") + assert _get_default_tz_name() == "US/Eastern" +# #endregion test_get_default_tz_name_custom + + +# #region test_get_app_timezone_returns_zoneinfo [C:2] [TYPE Function] +# @BRIEF Returns a ZoneInfo instance for the configured timezone. +def test_get_app_timezone_returns_zoneinfo(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow") + tz = get_app_timezone() + assert isinstance(tz, ZoneInfo) + assert tz.key == "Europe/Moscow" +# #endregion test_get_app_timezone_returns_zoneinfo + + +# #region test_get_app_timezone_cached [C:2] [TYPE Function] +# @BRIEF Two consecutive calls return the exact same object (identity). +def test_get_app_timezone_cached(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "Europe/Berlin") + first = get_app_timezone() + second = get_app_timezone() + assert first is second +# #endregion test_get_app_timezone_cached + + +# #region test_invalidate_and_recreate_cycle [C:2] [TYPE Function] +# @BRIEF After invalidate, next call re-reads env and returns a new object. +def test_invalidate_and_recreate_cycle(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow") + before = get_app_timezone() + assert before.key == "Europe/Moscow" + + invalidate_timezone_cache() + monkeypatch.setenv("APP_TIMEZONE", "US/Eastern") + after = get_app_timezone() + assert after.key == "US/Eastern" + assert before is not after +# #endregion test_invalidate_and_recreate_cycle + + +# #region test_validate_timezone_valid [C:2] [TYPE Function] +# @BRIEF Known IANA timezone names return True. +@pytest.mark.parametrize("tz_name", ["Europe/Moscow", "UTC", "US/Eastern", "Asia/Tokyo"]) +def test_validate_timezone_valid(tz_name): + assert validate_timezone(tz_name) is True +# #endregion test_validate_timezone_valid + + +# #region test_validate_timezone_invalid [C:2] [TYPE Function] +# @BRIEF Unknown or garbage timezone names return False. +@pytest.mark.parametrize("tz_name", ["Invalid/Timezone", "NotATZ", "Foo/Bar/Baz"]) +def test_validate_timezone_invalid(tz_name): + assert validate_timezone(tz_name) is False +# #endregion test_validate_timezone_invalid + + +# #region test_validate_timezone_none [C:2] [TYPE Function] +# @BRIEF None input returns False (TypeError caught internally). +def test_validate_timezone_none(): + assert validate_timezone(None) is False +# #endregion test_validate_timezone_none + + +# #region test_validate_timezone_empty_string_raises [C:2] [TYPE Function] +# @BRIEF BUG: empty string raises ValueError — production code only catches KeyError/TypeError. +def test_validate_timezone_empty_string_raises(): + with pytest.raises(ValueError, match="normalized relative paths"): + validate_timezone("") +# #endregion test_validate_timezone_empty_string_raises + + +# #region test_localize_naive_datetime [C:2] [TYPE Function] +# @BRIEF Naive UTC datetime is treated as UTC and converted to app timezone. +def test_localize_naive_datetime(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow") + invalidate_timezone_cache() + naive_utc = datetime(2024, 6, 15, 12, 0, 0) + result = localize(naive_utc) + # Hardcoded fixture: Moscow is UTC+3 in June (no DST) + assert result.hour == 15 + assert result.tzinfo is not None + assert result.tzinfo.key == "Europe/Moscow" +# #endregion test_localize_naive_datetime + + +# #region test_localize_aware_datetime [C:2] [TYPE Function] +# @BRIEF Aware datetime in a different timezone is converted to app timezone. +def test_localize_aware_datetime(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow") + invalidate_timezone_cache() + aware_utc = datetime(2024, 1, 15, 10, 0, 0, tzinfo=ZoneInfo("UTC")) + result = localize(aware_utc) + # Hardcoded fixture: Moscow is UTC+3 in January (no DST) + assert result.hour == 13 + assert result.tzinfo.key == "Europe/Moscow" +# #endregion test_localize_aware_datetime + + +# #region test_localize_none [C:2] [TYPE Function] +# @BRIEF None input returns None without error. +def test_localize_none(): + assert localize(None) is None +# #endregion test_localize_none + + +# #region test_now_returns_aware [C:2] [TYPE Function] +# @BRIEF now() returns a timezone-aware datetime in the app timezone. +def test_now_returns_aware(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "UTC") + invalidate_timezone_cache() + result = now() + assert result.tzinfo is not None + assert result.tzinfo.key == "UTC" +# #endregion test_now_returns_aware + + +# #region test_format_timezone_offset [C:2] [TYPE Function] +# @BRIEF Returns formatted UTC offset string like "+03:00". +def test_format_timezone_offset(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow") + invalidate_timezone_cache() + offset = format_timezone_offset() + # Hardcoded fixture: Moscow is always UTC+3 + assert offset == "+03:00" +# #endregion test_format_timezone_offset + + +# #region test_format_timezone_offset_utc [C:2] [TYPE Function] +# @BRIEF UTC timezone returns "+00:00". +def test_format_timezone_offset_utc(monkeypatch): + monkeypatch.setenv("APP_TIMEZONE", "UTC") + invalidate_timezone_cache() + offset = format_timezone_offset() + assert offset == "+00:00" +# #endregion test_format_timezone_offset_utc +# #endregion Test.AppTimezone diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index fba8ea47..ea9f44aa 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -27,6 +27,13 @@ import time from pathlib import Path import pytest + + +def pytest_collection_modifyitems(config, items): + if not config.getoption("--run-integration"): + skip_integration = pytest.mark.skip(reason="use --run-integration to run") + for item in items: + item.add_marker(skip_integration) import requests from sqlalchemy import create_engine, event, text from sqlalchemy.orm import Session, sessionmaker diff --git a/backend/tests/models/test_filter_state.py b/backend/tests/models/test_filter_state.py new file mode 100644 index 00000000..685cff94 --- /dev/null +++ b/backend/tests/models/test_filter_state.py @@ -0,0 +1,190 @@ +# #region Test.FilterStateModels [C:3] [TYPE Module] [SEMANTICS test,filter_state,models] +# @BRIEF Verify Pydantic filter-state models — creation, defaults, methods, and merge logic. +# @RELATION BINDS_TO -> [FilterStateModels] +# @TEST_EDGE: missing_filter_id -> get_extra_form_data returns empty dict +# @TEST_EDGE: empty_dataMask -> has_filters returns False +# @TEST_EDGE: append_non_list_fallback -> append key with non-list values falls through to override +import pytest + +from src.models.filter_state import ( + DashboardURLFilterExtraction, + ExtraFormDataMerge, + FilterState, + NativeFilterDataMask, + ParsedNativeFilters, +) + + +class TestFilterStateModel: + """Verify FilterState creation and defaults.""" + + # #region test_filter_state_defaults [C:2] [TYPE Function] + # @BRIEF FilterState with no args produces empty dicts for all three fields. + def test_filter_state_defaults(self): + fs = FilterState() + assert fs.extraFormData == {} + assert fs.filterState == {} + assert fs.ownState == {} + # #endregion test_filter_state_defaults + + # #region test_filter_state_all_fields [C:2] [TYPE Function] + # @BRIEF FilterState with explicit values preserves every field. + def test_filter_state_all_fields(self): + fs = FilterState( + extraFormData={"time_range": "2024 : 2025"}, + filterState={"value": ["admin"]}, + ownState={"owner": "user_1"}, + ) + assert fs.extraFormData == {"time_range": "2024 : 2025"} + assert fs.filterState == {"value": ["admin"]} + assert fs.ownState == {"owner": "user_1"} + # #endregion test_filter_state_all_fields + + +class TestNativeFilterDataMaskModel: + """Verify NativeFilterDataMask container methods.""" + + # #region test_get_filter_ids [C:2] [TYPE Function] + # @BRIEF get_filter_ids returns all keys from the filters dict. + def test_get_filter_ids(self): + mask = NativeFilterDataMask(filters={ + "NATIVE_FILTER_ABC": FilterState(extraFormData={"col": "a"}), + "NATIVE_FILTER_XYZ": FilterState(extraFormData={"col": "b"}), + }) + assert mask.get_filter_ids() == ["NATIVE_FILTER_ABC", "NATIVE_FILTER_XYZ"] + # #endregion test_get_filter_ids + + # #region test_get_extra_form_data_existing [C:2] [TYPE Function] + # @BRIEF get_extra_form_data returns extraFormData for a known filter ID. + def test_get_extra_form_data_existing(self): + mask = NativeFilterDataMask(filters={ + "NATIVE_FILTER_1": FilterState(extraFormData={"time_range": "Last week"}), + }) + assert mask.get_extra_form_data("NATIVE_FILTER_1") == {"time_range": "Last week"} + # #endregion test_get_extra_form_data_existing + + # #region test_get_extra_form_data_missing [C:2] [TYPE Function] + # @BRIEF get_extra_form_data returns empty dict for unknown filter ID. + def test_get_extra_form_data_missing(self): + mask = NativeFilterDataMask(filters={ + "NATIVE_FILTER_1": FilterState(extraFormData={"col": "x"}), + }) + assert mask.get_extra_form_data("NONEXISTENT_ID") == {} + # #endregion test_get_extra_form_data_missing + + +class TestParsedNativeFiltersModel: + """Verify ParsedNativeFilters query methods.""" + + # #region test_has_filters_true [C:2] [TYPE Function] + # @BRIEF has_filters returns True when dataMask is non-empty. + def test_has_filters_true(self): + parsed = ParsedNativeFilters(dataMask={"f1": {}, "f2": {}}) + assert parsed.has_filters() is True + # #endregion test_has_filters_true + + # #region test_has_filters_false [C:2] [TYPE Function] + # @BRIEF has_filters returns False when dataMask is empty. + def test_has_filters_false(self): + parsed = ParsedNativeFilters() + assert parsed.has_filters() is False + # #endregion test_has_filters_false + + # #region test_get_filter_count [C:2] [TYPE Function] + # @BRIEF get_filter_count returns the number of entries in dataMask. + def test_get_filter_count(self): + parsed = ParsedNativeFilters(dataMask={"a": {}, "b": {}, "c": {}}) + assert parsed.get_filter_count() == 3 + # #endregion test_get_filter_count + + +class TestDashboardURLFilterExtractionModel: + """Verify DashboardURLFilterExtraction creation and defaults.""" + + # #region test_dashboard_url_extraction_all_fields [C:2] [TYPE Function] + # @BRIEF All fields populated are preserved correctly. + def test_dashboard_url_extraction_all_fields(self): + filters = ParsedNativeFilters(dataMask={"f1": {}}) + extraction = DashboardURLFilterExtraction( + url="https://superset/dashboard/42?native_filters=key1", + dashboard_id="42", + filter_type="permalink", + filters=filters, + success=False, + error="partial extraction", + ) + assert extraction.url == "https://superset/dashboard/42?native_filters=key1" + assert extraction.dashboard_id == "42" + assert extraction.filter_type == "permalink" + assert extraction.filters is filters + assert extraction.success is False + assert extraction.error == "partial extraction" + # #endregion test_dashboard_url_extraction_all_fields + + # #region test_dashboard_url_extraction_default_success [C:2] [TYPE Function] + # @BRIEF success defaults to True when not specified. + def test_dashboard_url_extraction_default_success(self): + extraction = DashboardURLFilterExtraction(url="https://superset/dashboard/1") + assert extraction.success is True + assert extraction.error is None + assert extraction.dashboard_id is None + # #endregion test_dashboard_url_extraction_default_success + + +class TestExtraFormDataMergeModel: + """Verify ExtraFormDataMerge configuration and merge logic.""" + + # #region test_merge_default_config [C:2] [TYPE Function] + # @BRIEF Default append_keys and override_keys match the documented lists. + def test_merge_default_config(self): + cfg = ExtraFormDataMerge() + assert cfg.append_keys == ["filters", "extras", "columns", "metrics"] + assert cfg.override_keys == ["time_range", "time_grain_sqla", "time_column", "granularity"] + # #endregion test_merge_default_config + + # #region test_merge_override_keys [C:2] [TYPE Function] + # @BRIEF Override keys replace original values entirely. + def test_merge_override_keys(self): + cfg = ExtraFormDataMerge() + original = {"time_range": "2023 : 2024", "time_grain_sqla": "DAY"} + new = {"time_range": "2024 : 2025", "time_grain_sqla": "MONTH"} + result = cfg.merge(original, new) + assert result["time_range"] == "2024 : 2025" + assert result["time_grain_sqla"] == "MONTH" + # #endregion test_merge_override_keys + + # #region test_merge_append_keys [C:2] [TYPE Function] + # @BRIEF Append keys concatenate lists from original and new. + def test_merge_append_keys(self): + cfg = ExtraFormDataMerge() + original = {"filters": [{"col": "a"}], "columns": ["x"]} + new = {"filters": [{"col": "b"}], "columns": ["y", "z"]} + result = cfg.merge(original, new) + assert result["filters"] == [{"col": "a"}, {"col": "b"}] + assert result["columns"] == ["x", "y", "z"] + # #endregion test_merge_append_keys + + # #region test_merge_non_special_keys [C:2] [TYPE Function] + # @BRIEF Keys not in override or append lists fall through — new replaces original. + def test_merge_non_special_keys(self): + cfg = ExtraFormDataMerge() + original = {"custom_key": "old_value", "another": 1} + new = {"custom_key": "new_value", "brand_new": True} + result = cfg.merge(original, new) + assert result["custom_key"] == "new_value" + assert result["another"] == 1 + assert result["brand_new"] is True + # #endregion test_merge_non_special_keys + + # #region test_merge_append_non_list_fallback [C:2] [TYPE Function] + # @BRIEF Append key with non-list values falls through to simple override. + def test_merge_append_non_list_fallback(self): + cfg = ExtraFormDataMerge() + original = {"filters": "not_a_list"} + new = {"filters": ["real_filter"]} + result = cfg.merge(original, new) + assert result["filters"] == ["real_filter"] + # #endregion test_merge_append_non_list_fallback + + +# #endregion Test.FilterStateModels diff --git a/backend/tests/services/git/test_git_branch.py b/backend/tests/services/git/test_git_branch.py new file mode 100644 index 00000000..252b1e33 --- /dev/null +++ b/backend/tests/services/git/test_git_branch.py @@ -0,0 +1,339 @@ +# #region Test.Git.Branch [C:3] [TYPE Module] [SEMANTICS test,git,branch,checkout,commit,gitflow] +# @BRIEF Tests for GitServiceBranchMixin — gitflow bootstrap, list/create/checkout branches, commit changes. +# @RELATION BINDS_TO -> [GitServiceBranchMixin] +# @TEST_EDGE: empty_repo -> initial commit created for branching +# @TEST_EDGE: no_commits -> gitflow bootstrap skipped +# @TEST_EDGE: checkout_local_changes -> HTTPException 409 +# @TEST_EDGE: checkout_generic_error -> HTTPException 500 +# @TEST_EDGE: commit_dirty -> stages all and commits +# @TEST_EDGE: commit_clean -> no commit when not dirty +# @TEST_EDGE: commit_specific_files -> only listed files staged +# @TEST_EDGE: list_branches_filters_tags -> refs/tags/ excluded +# @TEST_EDGE: create_branch_from_missing -> falls back to HEAD + +import contextlib +import os +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import pytest +from unittest.mock import MagicMock, patch, call, PropertyMock + +from fastapi import HTTPException +from git.exc import GitCommandError + +from src.services.git._branch import GitServiceBranchMixin + + +class TestableGitBranch(GitServiceBranchMixin): + """Concrete test class providing _locked and get_repo stubs.""" + def __init__(self, mock_repo=None): + self._mock_repo = mock_repo or MagicMock() + + async def get_repo(self, dashboard_id): + return self._mock_repo + + def _locked(self, dashboard_id): + @contextlib.contextmanager + def _lock(): + yield + return _lock() + + +# ── _ensure_gitflow_branches ── + +class TestEnsureGitflowBranches: + """_ensure_gitflow_branches — main/dev/preprod bootstrap.""" + + # #region test_gitflow_no_commits [C:2] [TYPE Function] + def test_gitflow_no_commits(self): + """Empty repo (no commits) → skip bootstrap, no error.""" + repo = MagicMock() + type(repo.head).commit = property(lambda self: (_ for _ in StopIteration()).throw(ValueError("no commits"))) + repo.heads = [] + svc = TestableGitBranch() + svc._ensure_gitflow_branches(repo, 1) + repo.create_head.assert_not_called() + # #endregion test_gitflow_no_commits + + # #region test_gitflow_creates_missing_branches [C:2] [TYPE Function] + def test_gitflow_creates_missing_branches(self): + """Missing dev/preprod → created from main.""" + repo = MagicMock() + main_head = MagicMock() + main_head.name = "main" + main_head.commit = MagicMock() + repo.heads = [main_head] + repo.head.commit = MagicMock() + origin = MagicMock() + origin.refs = [] + repo.remote.return_value = origin + svc = TestableGitBranch() + svc._ensure_gitflow_branches(repo, 1) + created_names = [c.args[0] for c in repo.create_head.call_args_list] + assert "dev" in created_names + assert "preprod" in created_names + # #endregion test_gitflow_creates_missing_branches + + # #region test_gitflow_no_origin [C:2] [TYPE Function] + def test_gitflow_no_origin(self): + """No origin remote → skip remote push, no error.""" + repo = MagicMock() + main_head = MagicMock() + main_head.name = "main" + main_head.commit = MagicMock() + repo.heads = [main_head] + repo.head.commit = MagicMock() + repo.remote.side_effect = ValueError("no origin") + svc = TestableGitBranch() + # Should not raise + svc._ensure_gitflow_branches(repo, 1) + # #endregion test_gitflow_no_origin + + # #region test_gitflow_already_on_dev [C:2] [TYPE Function] + def test_gitflow_already_on_dev(self): + """Already on dev → no checkout call.""" + repo = MagicMock() + main_head = MagicMock() + main_head.name = "main" + main_head.commit = MagicMock() + dev_head = MagicMock() + dev_head.name = "dev" + dev_head.commit = MagicMock() + preprod_head = MagicMock() + preprod_head.name = "preprod" + preprod_head.commit = MagicMock() + repo.heads = [main_head, dev_head, preprod_head] + repo.head.commit = MagicMock() + repo.active_branch.name = "dev" + origin = MagicMock() + origin.refs = [MagicMock(remote_head="main"), MagicMock(remote_head="dev"), MagicMock(remote_head="preprod")] + repo.remote.return_value = origin + svc = TestableGitBranch() + svc._ensure_gitflow_branches(repo, 1) + repo.git.checkout.assert_not_called() + # #endregion test_gitflow_already_on_dev + + +# ── list_branches ── + +class TestListBranches: + """list_branches — branch listing with tag filtering.""" + + # #region test_list_branches_basic [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_list_branches_basic(self): + """Returns branch dicts with name, commit_hash, is_remote.""" + repo = MagicMock() + ref1 = MagicMock() + ref1.name = "refs/heads/dev" + ref1.commit.hexsha = "abc123" + ref1.commit.committed_date = 1700000000 + ref1.is_remote.return_value = False + repo.refs = [ref1] + repo.active_branch.name = "dev" + svc = TestableGitBranch(repo) + result = await svc.list_branches(1) + assert any(b["name"] == "dev" for b in result) + assert result[0]["commit_hash"] == "abc123" + # #endregion test_list_branches_basic + + # #region test_list_branches_filters_tags [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_list_branches_filters_tags(self): + """refs/tags/ entries excluded from result.""" + repo = MagicMock() + tag_ref = MagicMock() + tag_ref.name = "refs/tags/v1.0" + tag_ref.commit.hexsha = "tag123" + tag_ref.commit.committed_date = 1700000000 + branch_ref = MagicMock() + branch_ref.name = "refs/heads/main" + branch_ref.commit.hexsha = "main123" + branch_ref.commit.committed_date = 1700000000 + branch_ref.is_remote.return_value = False + repo.refs = [tag_ref, branch_ref] + repo.active_branch.name = "main" + svc = TestableGitBranch(repo) + result = await svc.list_branches(1) + names = [b["name"] for b in result] + assert "v1.0" not in names + assert "main" in names + # #endregion test_list_branches_filters_tags + + # #region test_list_branches_deduplicates [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_list_branches_deduplicates(self): + """Same name from heads and remotes → only one entry.""" + repo = MagicMock() + local_ref = MagicMock() + local_ref.name = "refs/heads/dev" + local_ref.commit.hexsha = "abc" + local_ref.commit.committed_date = 1700000000 + local_ref.is_remote.return_value = False + remote_ref = MagicMock() + remote_ref.name = "refs/remotes/origin/dev" + remote_ref.commit.hexsha = "abc" + remote_ref.commit.committed_date = 1700000000 + remote_ref.is_remote.return_value = True + repo.refs = [local_ref, remote_ref] + repo.active_branch.name = "dev" + svc = TestableGitBranch(repo) + result = await svc.list_branches(1) + dev_entries = [b for b in result if b["name"] == "dev"] + assert len(dev_entries) == 1 + # #endregion test_list_branches_deduplicates + + # #region test_list_branches_detached_head [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_list_branches_detached_head(self): + """Detached HEAD → fallback 'dev' branch added if empty.""" + repo = MagicMock() + repo.refs = [] + type(repo.active_branch).name = PropertyMock(side_effect=TypeError("detached HEAD")) + svc = TestableGitBranch(repo) + result = await svc.list_branches(1) + assert any(b["name"] == "dev" for b in result) + # #endregion test_list_branches_detached_head + + +# ── create_branch ── + +class TestCreateBranch: + """create_branch — new branch from existing.""" + + # #region test_create_branch_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_create_branch_success(self): + """Branch created from specified source.""" + repo = MagicMock() + repo.heads = [MagicMock()] + repo.remotes = [MagicMock()] + repo.commit.return_value = MagicMock() + new_branch = MagicMock() + repo.create_head.return_value = new_branch + svc = TestableGitBranch(repo) + result = await svc.create_branch(1, "feature-x", "main") + repo.create_head.assert_called_with("feature-x", "main") + assert result == new_branch + # #endregion test_create_branch_success + + # #region test_create_branch_empty_repo [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_create_branch_empty_repo(self): + """Empty repo → initial commit created, then branch.""" + repo = MagicMock() + repo.heads = [] + repo.remotes = [] + repo.working_dir = "/tmp/repo" + new_branch = MagicMock() + repo.create_head.return_value = new_branch + svc = TestableGitBranch(repo) + with patch("os.path.exists", return_value=False), \ + patch("builtins.open", MagicMock()): + result = await svc.create_branch(1, "feature", "main") + repo.index.add.assert_called_with(["README.md"]) + repo.index.commit.assert_called_with("Initial commit") + # #endregion test_create_branch_empty_repo + + # #region test_create_branch_missing_source [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_create_branch_missing_source(self): + """Source branch not found → falls back to repo.head.""" + repo = MagicMock() + repo.heads = [MagicMock()] + repo.remotes = [MagicMock()] + repo.commit.side_effect = Exception("not found") + repo.head = MagicMock() + repo.create_head.return_value = MagicMock() + svc = TestableGitBranch(repo) + await svc.create_branch(1, "feature", "nonexistent") + repo.create_head.assert_called_with("feature", repo.head) + # #endregion test_create_branch_missing_source + + +# ── checkout_branch ── + +class TestCheckoutBranch: + """checkout_branch — switch active branch.""" + + # #region test_checkout_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_checkout_success(self): + """Successful checkout → git.checkout called.""" + repo = MagicMock() + svc = TestableGitBranch(repo) + await svc.checkout_branch(1, "main") + repo.git.checkout.assert_called_with("main") + # #endregion test_checkout_success + + # #region test_checkout_local_changes [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_checkout_local_changes(self): + """Local changes conflict → HTTPException 409.""" + repo = MagicMock() + error = GitCommandError("checkout", "error") + error.stderr = "error: Your local changes to the following files would be overwritten by checkout:\n\tconfig.yaml" + repo.git.checkout.side_effect = error + svc = TestableGitBranch(repo) + with pytest.raises(HTTPException) as exc_info: + await svc.checkout_branch(1, "main") + assert exc_info.value.status_code == 409 + detail = exc_info.value.detail + assert detail["error_code"] == "GIT_CHECKOUT_LOCAL_CHANGES" + # #endregion test_checkout_local_changes + + # #region test_checkout_generic_error [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_checkout_generic_error(self): + """Other GitCommandError → HTTPException 500.""" + repo = MagicMock() + repo.git.checkout.side_effect = GitCommandError("checkout", "fatal: bad ref") + svc = TestableGitBranch(repo) + with pytest.raises(HTTPException) as exc_info: + await svc.checkout_branch(1, "bad-branch") + assert exc_info.value.status_code == 500 + # #endregion test_checkout_generic_error + + +# ── commit_changes ── + +class TestCommitChanges: + """commit_changes — stage and commit.""" + + # #region test_commit_dirty_all [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_commit_dirty_all(self): + """Dirty repo, no files → stage all and commit.""" + repo = MagicMock() + repo.is_dirty.return_value = True + svc = TestableGitBranch(repo) + await svc.commit_changes(1, "update config") + repo.git.add.assert_called_with(A=True) + repo.index.commit.assert_called_with("update config") + # #endregion test_commit_dirty_all + + # #region test_commit_clean [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_commit_clean(self): + """Clean repo, no files → no commit.""" + repo = MagicMock() + repo.is_dirty.return_value = False + svc = TestableGitBranch(repo) + await svc.commit_changes(1, "nothing") + repo.index.commit.assert_not_called() + # #endregion test_commit_clean + + # #region test_commit_specific_files [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_commit_specific_files(self): + """Files list → only those files staged.""" + repo = MagicMock() + repo.is_dirty.return_value = False + svc = TestableGitBranch(repo) + await svc.commit_changes(1, "fix", files=["a.yaml", "b.yaml"]) + repo.index.add.assert_called_with(["a.yaml", "b.yaml"]) + repo.index.commit.assert_called_with("fix") + # #endregion test_commit_specific_files +# #endregion Test.Git.Branch diff --git a/backend/tests/services/git/test_git_gitea.py b/backend/tests/services/git/test_git_gitea.py new file mode 100644 index 00000000..c8ab9e1d --- /dev/null +++ b/backend/tests/services/git/test_git_gitea.py @@ -0,0 +1,480 @@ +# #region Test.Git.Gitea [C:3] [TYPE Module] [SEMANTICS test,git,gitea,api,http,pull-request] +# @BRIEF Tests for GitServiceGiteaMixin — connection testing, headers, API requests, user resolution, repo CRUD, PR creation with fallback. +# @RELATION BINDS_TO -> [GitServiceGiteaMixin] +# @TEST_EDGE: local_url -> returns True without API call +# @TEST_EDGE: invalid_protocol -> returns False +# @TEST_EDGE: empty_pat -> returns False +# @TEST_EDGE: api_error -> HTTPException with status code +# @TEST_EDGE: network_error -> HTTPException 503 +# @TEST_EDGE: empty_pat_headers -> HTTPException 400 +# @TEST_EDGE: create_repo_conflict -> fetches existing repo +# @TEST_EDGE: pr_404_fallback -> retries with derived URL +# @TEST_EDGE: pr_404_branch_missing -> HTTPException 400 with detail +# @TEST_EDGE: delete_missing_owner -> HTTPException 400 + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from fastapi import HTTPException + +from src.models.git import GitProvider +from src.services.git._gitea import GitServiceGiteaMixin + + +class TestableGitGitea(GitServiceGiteaMixin): + """Concrete test class providing _http_client and URL mixin stubs.""" + def __init__(self): + self._http_client = AsyncMock() + + def _normalize_git_server_url(self, raw_url): + normalized = (raw_url or "").strip() + if not normalized: + raise HTTPException(status_code=400, detail="Git server URL is required") + return normalized.rstrip("/") + + def _parse_remote_repo_identity(self, remote_url): + from urllib.parse import urlparse + normalized = str(remote_url or "").strip() + if not normalized: + raise HTTPException(status_code=400, detail="empty") + if normalized.startswith("git@"): + path = normalized.split(":", 1)[1] if ":" in normalized else "" + else: + parsed = urlparse(normalized) + path = parsed.path or "" + path = path.strip("/") + if path.endswith(".git"): + path = path[:-4] + parts = [s for s in path.split("/") if s] + if len(parts) < 2: + raise HTTPException(status_code=400, detail="Cannot parse") + owner, repo = parts[0], parts[-1] + namespace = "/".join(parts[:-1]) + return {"owner": owner, "repo": repo, "namespace": namespace, "full_name": f"{namespace}/{repo}"} + + def _derive_server_url_from_remote(self, remote_url): + from urllib.parse import urlparse + normalized = str(remote_url or "").strip() + if not normalized or normalized.startswith("git@"): + return None + parsed = urlparse(normalized) + if parsed.scheme not in {"http", "https"}: + return None + if not parsed.hostname: + return None + netloc = parsed.hostname + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + return f"{parsed.scheme}://{netloc}" + + +# ── test_connection ── + +class TestTestConnection: + """test_connection — provider connectivity check.""" + + # #region test_connection_local_url [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_local_url(self): + """Local/localhost URL → True without API call.""" + svc = TestableGitGitea() + result = await svc.test_connection(GitProvider.GITEA, "http://gitea.local:3000", "pat") + assert result is True + svc._http_client.get.assert_not_called() + # #endregion test_connection_local_url + + # #region test_connection_invalid_protocol [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_invalid_protocol(self): + """Non-HTTP URL → False.""" + svc = TestableGitGitea() + result = await svc.test_connection(GitProvider.GITEA, "ftp://host.com", "pat") + assert result is False + # #endregion test_connection_invalid_protocol + + # #region test_connection_empty_pat [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_empty_pat(self): + """Empty PAT → False.""" + svc = TestableGitGitea() + result = await svc.test_connection(GitProvider.GITEA, "https://gitea.com", "") + assert result is False + # #endregion test_connection_empty_pat + + # #region test_connection_gitea_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_gitea_success(self): + """Gitea 200 → True.""" + svc = TestableGitGitea() + resp = MagicMock() + resp.status_code = 200 + svc._http_client.get.return_value = resp + result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "valid-pat") + assert result is True + svc._http_client.get.assert_called_once() + call_url = svc._http_client.get.call_args[0][0] + assert "/api/v1/user" in call_url + # #endregion test_connection_gitea_success + + # #region test_connection_github_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_github_success(self): + """GitHub 200 → True.""" + svc = TestableGitGitea() + resp = MagicMock() + resp.status_code = 200 + svc._http_client.get.return_value = resp + result = await svc.test_connection(GitProvider.GITHUB, "https://github.com", "valid-pat") + assert result is True + call_url = svc._http_client.get.call_args[0][0] + assert "api.github.com" in call_url + # #endregion test_connection_github_success + + # #region test_connection_gitlab_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_gitlab_success(self): + """GitLab 200 → True.""" + svc = TestableGitGitea() + resp = MagicMock() + resp.status_code = 200 + svc._http_client.get.return_value = resp + result = await svc.test_connection(GitProvider.GITLAB, "https://gitlab.com", "valid-pat") + assert result is True + call_url = svc._http_client.get.call_args[0][0] + assert "/api/v4/user" in call_url + # #endregion test_connection_gitlab_success + + # #region test_connection_failure_status [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_failure_status(self): + """Non-200 response → False.""" + svc = TestableGitGitea() + resp = MagicMock() + resp.status_code = 401 + svc._http_client.get.return_value = resp + result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "bad-pat") + assert result is False + # #endregion test_connection_failure_status + + # #region test_connection_network_error [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_network_error(self): + """Network exception → False.""" + svc = TestableGitGitea() + svc._http_client.get.side_effect = Exception("connection refused") + result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "pat") + assert result is False + # #endregion test_connection_network_error + + # #region test_connection_unknown_provider [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_connection_unknown_provider(self): + """Unknown provider → False.""" + svc = TestableGitGitea() + result = await svc.test_connection("BITBUCKET", "https://bitbucket.org", "pat") + assert result is False + # #endregion test_connection_unknown_provider + + +# ── _gitea_headers ── + +class TestGiteaHeaders: + """_gitea_headers — authorization header construction.""" + + # #region test_headers_valid_pat [C:2] [TYPE Function] + def test_headers_valid_pat(self): + """Valid PAT → Authorization token header.""" + svc = TestableGitGitea() + headers = svc._gitea_headers("my-token") + assert headers["Authorization"] == "token my-token" + assert headers["Content-Type"] == "application/json" + # #endregion test_headers_valid_pat + + # #region test_headers_empty_pat [C:2] [TYPE Function] + def test_headers_empty_pat(self): + """Empty PAT → HTTPException 400.""" + svc = TestableGitGitea() + with pytest.raises(HTTPException, match="PAT is required"): + svc._gitea_headers("") + # #endregion test_headers_empty_pat + + +# ── _gitea_request ── + +class TestGiteaRequest: + """_gitea_request — HTTP request wrapper with error mapping.""" + + # #region test_request_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_request_success(self): + """200 response → JSON payload returned.""" + svc = TestableGitGitea() + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = {"login": "admin"} + svc._http_client.request.return_value = resp + result = await svc._gitea_request("GET", "https://gitea.com", "pat", "/user") + assert result == {"login": "admin"} + # #endregion test_request_success + + # #region test_request_204_no_content [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_request_204_no_content(self): + """204 → returns None.""" + svc = TestableGitGitea() + resp = MagicMock() + resp.status_code = 204 + svc._http_client.request.return_value = resp + result = await svc._gitea_request("DELETE", "https://gitea.com", "pat", "/repos/o/r") + assert result is None + # #endregion test_request_204_no_content + + # #region test_request_api_error [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_request_api_error(self): + """4xx/5xx → HTTPException with detail.""" + svc = TestableGitGitea() + resp = MagicMock() + resp.status_code = 404 + resp.text = "Not Found" + resp.json.return_value = {"message": "repository not found"} + svc._http_client.request.return_value = resp + with pytest.raises(HTTPException, match="repository not found"): + await svc._gitea_request("GET", "https://gitea.com", "pat", "/repos/o/r") + # #endregion test_request_api_error + + # #region test_request_network_error [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_request_network_error(self): + """Network exception → HTTPException 503.""" + svc = TestableGitGitea() + svc._http_client.request.side_effect = Exception("connection refused") + with pytest.raises(HTTPException, match="unavailable"): + await svc._gitea_request("GET", "https://gitea.com", "pat", "/user") + # #endregion test_request_network_error + + +# ── get_gitea_current_user ── + +class TestGetGiteaCurrentUser: + """get_gitea_current_user — resolve authenticated username.""" + + # #region test_current_user_login [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_current_user_login(self): + """Returns 'login' field from API response.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"login": "admin"}): + result = await svc.get_gitea_current_user("https://gitea.com", "pat") + assert result == "admin" + # #endregion test_current_user_login + + # #region test_current_user_username_fallback [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_current_user_username_fallback(self): + """Falls back to 'username' field.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"username": "bob"}): + result = await svc.get_gitea_current_user("https://gitea.com", "pat") + assert result == "bob" + # #endregion test_current_user_username_fallback + + # #region test_current_user_missing [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_current_user_missing(self): + """No login/username → HTTPException 500.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={}): + with pytest.raises(HTTPException, match="Failed to resolve"): + await svc.get_gitea_current_user("https://gitea.com", "pat") + # #endregion test_current_user_missing + + +# ── list_gitea_repositories ── + +class TestListGiteaRepositories: + """list_gitea_repositories — list user repos.""" + + # #region test_list_repos_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_list_repos_success(self): + """Returns list from API.""" + svc = TestableGitGitea() + repos = [{"name": "repo1"}, {"name": "repo2"}] + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=repos): + result = await svc.list_gitea_repositories("https://gitea.com", "pat") + assert len(result) == 2 + # #endregion test_list_repos_success + + # #region test_list_repos_non_list [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_list_repos_non_list(self): + """Non-list response → returns [].""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"error": "bad"}): + result = await svc.list_gitea_repositories("https://gitea.com", "pat") + assert result == [] + # #endregion test_list_repos_non_list + + +# ── create_gitea_repository ── + +class TestCreateGiteaRepository: + """create_gitea_repository — create or fetch existing.""" + + # #region test_create_repo_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_create_repo_success(self): + """200 → returns created repo dict.""" + svc = TestableGitGitea() + created = {"name": "new-repo", "clone_url": "https://gitea.com/user/new-repo.git"} + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=created): + result = await svc.create_gitea_repository("https://gitea.com", "pat", "new-repo") + assert result["name"] == "new-repo" + # #endregion test_create_repo_success + + # #region test_create_repo_conflict_fetches_existing [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_create_repo_conflict_fetches_existing(self): + """409 → fetches existing repo by name.""" + svc = TestableGitGitea() + existing = {"name": "existing-repo", "clone_url": "https://gitea.com/user/existing-repo.git"} + call_count = 0 + async def mock_request(method, url, pat, endpoint, payload=None): + nonlocal call_count + call_count += 1 + if method == "POST": + raise HTTPException(status_code=409, detail="conflict") + if endpoint.startswith("/user"): + return {"login": "user"} + return existing + with patch.object(svc, "_gitea_request", side_effect=mock_request): + result = await svc.create_gitea_repository("https://gitea.com", "pat", "existing-repo") + assert result["name"] == "existing-repo" + # #endregion test_create_repo_conflict_fetches_existing + + # #region test_create_repo_unexpected_response [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_create_repo_unexpected_response(self): + """Non-dict response → HTTPException 500.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value="not a dict"): + with pytest.raises(HTTPException, match="Unexpected"): + await svc.create_gitea_repository("https://gitea.com", "pat", "repo") + # #endregion test_create_repo_unexpected_response + + +# ── delete_gitea_repository ── + +class TestDeleteGiteaRepository: + """delete_gitea_repository — delete remote repo.""" + + # #region test_delete_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_delete_success(self): + """Valid owner/repo → DELETE request made.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=None) as mock_req: + await svc.delete_gitea_repository("https://gitea.com", "pat", "owner", "repo") + mock_req.assert_called_once() + assert mock_req.call_args[0][0] == "DELETE" + # #endregion test_delete_success + + # #region test_delete_missing_owner [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_delete_missing_owner(self): + """Empty owner → HTTPException 400.""" + svc = TestableGitGitea() + with pytest.raises(HTTPException, match="required"): + await svc.delete_gitea_repository("https://gitea.com", "pat", "", "repo") + # #endregion test_delete_missing_owner + + +# ── _gitea_branch_exists ── + +class TestGiteaBranchExists: + """_gitea_branch_exists — check branch existence.""" + + # #region test_branch_exists_true [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_branch_exists_true(self): + """200 → True.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"name": "dev"}): + result = await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "dev") + assert result is True + # #endregion test_branch_exists_true + + # #region test_branch_exists_404 [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_branch_exists_404(self): + """404 → False.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", side_effect=HTTPException(status_code=404)): + result = await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "missing") + assert result is False + # #endregion test_branch_exists_404 + + # #region test_branch_exists_empty_params [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_branch_exists_empty_params(self): + """Empty owner/repo/branch → False.""" + svc = TestableGitGitea() + assert await svc._gitea_branch_exists("https://gitea.com", "pat", "", "repo", "dev") is False + assert await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "", "dev") is False + assert await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "") is False + # #endregion test_branch_exists_empty_params + + +# ── create_gitea_pull_request ── + +class TestCreateGiteaPullRequest: + """create_gitea_pull_request — PR creation with fallback.""" + + # #region test_pr_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pr_success(self): + """Successful PR → normalized metadata.""" + svc = TestableGitGitea() + pr_data = {"number": 42, "html_url": "https://gitea.com/o/r/pulls/42", "state": "open"} + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=pr_data): + result = await svc.create_gitea_pull_request( + "https://gitea.com", "pat", "https://gitea.com/owner/repo.git", + "feature", "main", "Add feature", + ) + assert result["id"] == 42 + assert result["status"] == "open" + assert "pulls/42" in result["url"] + # #endregion test_pr_success + + # #region test_pr_404_non_retryable [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pr_404_non_retryable(self): + """404 with no fallback URL → raises with branch detail.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", side_effect=HTTPException(status_code=404, detail="not found")), \ + patch.object(svc, "_build_gitea_pr_404_detail", new_callable=AsyncMock, return_value=None): + with pytest.raises(HTTPException, match="not found"): + await svc.create_gitea_pull_request( + "https://gitea.com", "pat", "https://gitea.com/owner/repo.git", + "feature", "main", "PR title", + ) + # #endregion test_pr_404_non_retryable + + # #region test_pr_unexpected_response [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pr_unexpected_response(self): + """Non-dict response → HTTPException 500.""" + svc = TestableGitGitea() + with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value="not a dict"): + with pytest.raises(HTTPException, match="Unexpected"): + await svc.create_gitea_pull_request( + "https://gitea.com", "pat", "https://gitea.com/owner/repo.git", + "feature", "main", "PR title", + ) + # #endregion test_pr_unexpected_response +# #endregion Test.Git.Gitea diff --git a/backend/tests/services/git/test_git_merge.py b/backend/tests/services/git/test_git_merge.py new file mode 100644 index 00000000..55de6e43 --- /dev/null +++ b/backend/tests/services/git/test_git_merge.py @@ -0,0 +1,435 @@ +# #region Test.Git.Merge [C:3] [TYPE Module] [SEMANTICS test,git,merge,conflict,resolution] +# @BRIEF Tests for GitServiceMergeMixin — merge status, conflict listing, resolution, abort, continue, direct promote. +# @RELATION BINDS_TO -> [GitServiceMergeMixin] +# @TEST_EDGE: no_merge_in_progress -> has_unfinished_merge=false +# @TEST_EDGE: unfinished_merge -> MERGE_HEAD present, payload built +# @TEST_EDGE: conflict_list -> unmerged blobs parsed with mine/theirs +# @TEST_EDGE: resolve_mine -> checkout --ours called +# @TEST_EDGE: resolve_theirs -> checkout --theirs called +# @TEST_EDGE: resolve_manual -> file written with content +# @TEST_EDGE: resolve_invalid_strategy -> HTTPException 400 +# @TEST_EDGE: resolve_empty_filepath -> HTTPException 400 +# @TEST_EDGE: abort_no_merge -> returns no_merge_in_progress +# @TEST_EDGE: continue_with_conflicts -> HTTPException 409 +# @TEST_EDGE: promote_same_branch -> HTTPException 400 +# @TEST_EDGE: promote_missing_branch -> HTTPException 404 + +import contextlib +import os +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch, mock_open + +from fastapi import HTTPException + +from src.services.git._merge import GitServiceMergeMixin + + +class TestableGitMerge(GitServiceMergeMixin): + """Concrete test class providing _locked stub. get_repo must be set per-test.""" + def __init__(self, mock_repo=None): + self._mock_repo = mock_repo or MagicMock() + + def _locked(self, dashboard_id): + @contextlib.contextmanager + def _lock(): + yield + return _lock() + + +# ── _read_blob_text ── + +class TestReadBlobText: + """_read_blob_text — blob content decoding.""" + + # #region test_blob_none [C:2] [TYPE Function] + def test_blob_none(self): + """None blob returns empty string.""" + svc = TestableGitMerge() + assert svc._read_blob_text(None) == "" + # #endregion test_blob_none + + # #region test_blob_valid [C:2] [TYPE Function] + def test_blob_valid(self): + """Valid blob decoded as UTF-8.""" + svc = TestableGitMerge() + blob = MagicMock() + blob.data_stream.read.return_value = b"hello world" + assert svc._read_blob_text(blob) == "hello world" + # #endregion test_blob_valid + + # #region test_blob_decode_error [C:2] [TYPE Function] + def test_blob_decode_error(self): + """Decode exception returns empty string.""" + svc = TestableGitMerge() + blob = MagicMock() + blob.data_stream.read.side_effect = Exception("binary data") + assert svc._read_blob_text(blob) == "" + # #endregion test_blob_decode_error + + +# ── _get_unmerged_file_paths ── + +class TestGetUnmergedFilePaths: + """_get_unmerged_file_paths — list conflicted files.""" + + # #region test_unmerged_files [C:2] [TYPE Function] + def test_unmerged_files(self): + """Returns sorted list of conflicted file paths.""" + svc = TestableGitMerge() + repo = MagicMock() + repo.index.unmerged_blobs.return_value = {"b.txt": [], "a.txt": []} + result = svc._get_unmerged_file_paths(repo) + assert result == ["a.txt", "b.txt"] + # #endregion test_unmerged_files + + # #region test_unmerged_exception [C:2] [TYPE Function] + def test_unmerged_exception(self): + """Exception returns empty list.""" + svc = TestableGitMerge() + repo = MagicMock() + repo.index.unmerged_blobs.side_effect = Exception("index error") + assert svc._get_unmerged_file_paths(repo) == [] + # #endregion test_unmerged_exception + + +# ── get_merge_status ── + +class TestGetMergeStatus: + """get_merge_status — unfinished merge detection.""" + + # #region test_no_merge_in_progress [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_no_merge_in_progress(self): + """No MERGE_HEAD → has_unfinished_merge=false.""" + repo = MagicMock() + repo.git_dir = "/tmp/fake_repo/.git" + repo.active_branch.name = "dev" + repo.working_tree_dir = "/tmp/fake_repo" + svc = TestableGitMerge(repo) + svc.get_repo = AsyncMock(return_value=repo) + with patch("os.path.exists", return_value=False): + result = await svc.get_merge_status(1) + assert result["has_unfinished_merge"] is False + assert result["current_branch"] == "dev" + assert result["conflicts_count"] == 0 + # #endregion test_no_merge_in_progress + + # #region test_merge_in_progress [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_merge_in_progress(self): + """MERGE_HEAD exists → has_unfinished_merge=true.""" + repo = MagicMock() + repo.git_dir = "/tmp/fake_repo/.git" + repo.active_branch.name = "dev" + repo.working_tree_dir = "/tmp/fake_repo" + repo.index.unmerged_blobs.return_value = {"conflict.txt": [(2, MagicMock()), (3, MagicMock())]} + svc = TestableGitMerge(repo) + svc.get_repo = AsyncMock(return_value=repo) + with patch("os.path.exists", return_value=True), \ + patch("pathlib.Path.read_text", return_value="abc123def456"): + result = await svc.get_merge_status(1) + assert result["has_unfinished_merge"] is True + assert result["merge_head"] == "abc123def456" + assert result["conflicts_count"] >= 0 + # #endregion test_merge_in_progress + + +# ── get_merge_conflicts ── + +class TestGetMergeConflicts: + """get_merge_conflicts — list conflict details.""" + + # #region test_conflicts_listed [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_conflicts_listed(self): + """Unmerged blobs parsed into mine/theirs content.""" + repo = MagicMock() + mine_blob = MagicMock() + mine_blob.data_stream.read.return_value = b"mine content" + theirs_blob = MagicMock() + theirs_blob.data_stream.read.return_value = b"theirs content" + repo.index.unmerged_blobs.return_value = { + "file.txt": [(2, mine_blob), (3, theirs_blob)], + } + svc = TestableGitMerge(repo) + svc.get_repo = AsyncMock(return_value=repo) + result = await svc.get_merge_conflicts(1) + assert len(result) == 1 + assert result[0]["file_path"] == "file.txt" + assert result[0]["mine"] == "mine content" + assert result[0]["theirs"] == "theirs content" + # #endregion test_conflicts_listed + + # #region test_no_conflicts [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_no_conflicts(self): + """No unmerged blobs → empty list.""" + repo = MagicMock() + repo.index.unmerged_blobs.return_value = {} + svc = TestableGitMerge(repo) + svc.get_repo = AsyncMock(return_value=repo) + result = await svc.get_merge_conflicts(1) + assert result == [] + # #endregion test_no_conflicts + + +# ── resolve_merge_conflicts ── + +class TestResolveMergeConflicts: + """resolve_merge_conflicts — strategy-based resolution.""" + + # #region test_resolve_mine [C:2] [TYPE Function] + def test_resolve_mine(self): + """Strategy 'mine' → checkout --ours.""" + repo = MagicMock() + repo.working_tree_dir = "/tmp/repo" + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + result = svc.resolve_merge_conflicts(1, [{"file_path": "a.txt", "resolution": "mine"}]) + repo.git.checkout.assert_called_with("--ours", "--", "a.txt") + repo.git.add.assert_called_with("a.txt") + assert result == ["a.txt"] + # #endregion test_resolve_mine + + # #region test_resolve_theirs [C:2] [TYPE Function] + def test_resolve_theirs(self): + """Strategy 'theirs' → checkout --theirs.""" + repo = MagicMock() + repo.working_tree_dir = "/tmp/repo" + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + result = svc.resolve_merge_conflicts(1, [{"file_path": "b.txt", "resolution": "theirs"}]) + repo.git.checkout.assert_called_with("--theirs", "--", "b.txt") + assert result == ["b.txt"] + # #endregion test_resolve_theirs + + # #region test_resolve_manual [C:2] [TYPE Function] + def test_resolve_manual(self): + """Strategy 'manual' → writes content to file.""" + repo = MagicMock() + repo.working_tree_dir = "/tmp/repo" + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with patch("builtins.open", mock_open()) as mf, \ + patch("os.makedirs"), \ + patch("os.path.abspath", side_effect=lambda p: p if p.startswith("/") else f"/tmp/repo/{p}"): + result = svc.resolve_merge_conflicts(1, [ + {"file_path": "c.txt", "resolution": "manual", "content": "resolved text"}, + ]) + assert result == ["c.txt"] + repo.git.add.assert_called_with("c.txt") + # #endregion test_resolve_manual + + # #region test_resolve_invalid_strategy [C:2] [TYPE Function] + def test_resolve_invalid_strategy(self): + """Unknown strategy → HTTPException 400.""" + repo = MagicMock() + repo.working_tree_dir = "/tmp/repo" + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with pytest.raises(HTTPException, match="Unsupported"): + svc.resolve_merge_conflicts(1, [{"file_path": "x.txt", "resolution": "invalid"}]) + # #endregion test_resolve_invalid_strategy + + # #region test_resolve_empty_filepath [C:2] [TYPE Function] + def test_resolve_empty_filepath(self): + """Missing file_path → HTTPException 400.""" + repo = MagicMock() + repo.working_tree_dir = "/tmp/repo" + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with pytest.raises(HTTPException, match="file_path is required"): + svc.resolve_merge_conflicts(1, [{"file_path": "", "resolution": "mine"}]) + # #endregion test_resolve_empty_filepath + + # #region test_resolve_empty_resolutions [C:2] [TYPE Function] + def test_resolve_empty_resolutions(self): + """Empty resolutions list → returns [].""" + repo = MagicMock() + repo.working_tree_dir = "/tmp/repo" + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + assert svc.resolve_merge_conflicts(1, []) == [] + assert svc.resolve_merge_conflicts(1, None) == [] + # #endregion test_resolve_empty_resolutions + + +# ── abort_merge ── + +class TestAbortMerge: + """abort_merge — cancel ongoing merge.""" + + # #region test_abort_success [C:2] [TYPE Function] + def test_abort_success(self): + """Successful abort → status='aborted'.""" + repo = MagicMock() + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + result = svc.abort_merge(1) + repo.git.merge.assert_called_with("--abort") + assert result["status"] == "aborted" + # #endregion test_abort_success + + # #region test_abort_no_merge [C:2] [TYPE Function] + def test_abort_no_merge(self): + """No merge in progress → status='no_merge_in_progress'.""" + from git.exc import GitCommandError + repo = MagicMock() + repo.git.merge.side_effect = GitCommandError("merge", "fatal: There is no merge to abort") + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + result = svc.abort_merge(1) + assert result["status"] == "no_merge_in_progress" + # #endregion test_abort_no_merge + + # #region test_abort_conflict [C:2] [TYPE Function] + def test_abort_conflict(self): + """Other GitCommandError → HTTPException 409.""" + from git.exc import GitCommandError + repo = MagicMock() + repo.git.merge.side_effect = GitCommandError("merge", "fatal: something else") + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with pytest.raises(HTTPException, match="Cannot abort"): + svc.abort_merge(1) + # #endregion test_abort_conflict + + +# ── continue_merge ── + +class TestContinueMerge: + """continue_merge — finalize merge after resolution.""" + + # #region test_continue_with_message [C:2] [TYPE Function] + def test_continue_with_message(self): + """Commit with custom message.""" + repo = MagicMock() + repo.index.unmerged_blobs.return_value = {} + repo.head.commit.hexsha = "abc123" + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + result = svc.continue_merge(1, message="resolve conflicts") + repo.git.commit.assert_called_with("-m", "resolve conflicts") + assert result["status"] == "committed" + assert result["commit_hash"] == "abc123" + # #endregion test_continue_with_message + + # #region test_continue_no_message [C:2] [TYPE Function] + def test_continue_no_message(self): + """No message → --no-edit.""" + repo = MagicMock() + repo.index.unmerged_blobs.return_value = {} + repo.head.commit.hexsha = "def456" + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + result = svc.continue_merge(1) + repo.git.commit.assert_called_with("--no-edit") + assert result["status"] == "committed" + # #endregion test_continue_no_message + + # #region test_continue_unresolved_conflicts [C:2] [TYPE Function] + def test_continue_unresolved_conflicts(self): + """Unmerged files remain → HTTPException 409.""" + repo = MagicMock() + repo.index.unmerged_blobs.return_value = {"file.txt": [(2, MagicMock())]} + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with pytest.raises(HTTPException) as exc_info: + svc.continue_merge(1) + assert exc_info.value.status_code == 409 + # #endregion test_continue_unresolved_conflicts + + # #region test_continue_nothing_to_commit [C:2] [TYPE Function] + def test_continue_nothing_to_commit(self): + """Nothing to commit → status='already_clean'.""" + from git.exc import GitCommandError + repo = MagicMock() + repo.index.unmerged_blobs.return_value = {} + repo.git.commit.side_effect = GitCommandError("commit", "nothing to commit") + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + result = svc.continue_merge(1) + assert result["status"] == "already_clean" + # #endregion test_continue_nothing_to_commit + + +# ── promote_direct_merge ── + +class TestPromoteDirectMerge: + """promote_direct_merge — direct branch-to-branch merge.""" + + # #region test_promote_same_branch [C:2] [TYPE Function] + def test_promote_same_branch(self): + """Same from/to → HTTPException 400.""" + repo = MagicMock() + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with pytest.raises(HTTPException, match="must be different"): + svc.promote_direct_merge(1, "dev", "dev") + # #endregion test_promote_same_branch + + # #region test_promote_empty_branch [C:2] [TYPE Function] + def test_promote_empty_branch(self): + """Empty branch name → HTTPException 400.""" + repo = MagicMock() + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with pytest.raises(HTTPException, match="required"): + svc.promote_direct_merge(1, "", "main") + # #endregion test_promote_empty_branch + + # #region test_promote_success [C:2] [TYPE Function] + def test_promote_success(self): + """Successful merge → status='merged'.""" + repo = MagicMock() + repo.active_branch.name = "dev" + repo.heads = [MagicMock(name="dev"), MagicMock(name="main")] + repo.heads[0].name = "dev" + repo.heads[1].name = "main" + repo.refs = [MagicMock()] + repo.refs[0].name = "origin/main" + origin = MagicMock() + repo.remote.return_value = origin + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + result = svc.promote_direct_merge(1, "dev", "main") + assert result["status"] == "merged" + assert result["from_branch"] == "dev" + assert result["to_branch"] == "main" + origin.fetch.assert_called_once() + repo.git.merge.assert_called_once() + origin.push.assert_called_once() + repo.git.checkout.assert_called() + # #endregion test_promote_success + + # #region test_promote_no_origin [C:2] [TYPE Function] + def test_promote_no_origin(self): + """No origin remote → HTTPException 400.""" + repo = MagicMock() + repo.active_branch.name = "dev" + repo.remote.side_effect = ValueError("no remote") + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with pytest.raises(HTTPException, match="origin"): + svc.promote_direct_merge(1, "dev", "main") + # #endregion test_promote_no_origin + + # #region test_promote_source_not_found [C:2] [TYPE Function] + def test_promote_source_not_found(self): + """Source branch not in heads or refs → HTTPException 404.""" + repo = MagicMock() + repo.active_branch.name = "dev" + repo.heads = [] + repo.refs = [] + origin = MagicMock() + repo.remote.return_value = origin + svc = TestableGitMerge(repo) + svc.get_repo = MagicMock(return_value=repo) + with pytest.raises(HTTPException, match="not found"): + svc.promote_direct_merge(1, "nonexistent", "main") + # #endregion test_promote_source_not_found +# #endregion Test.Git.Merge diff --git a/backend/tests/services/git/test_git_sync.py b/backend/tests/services/git/test_git_sync.py new file mode 100644 index 00000000..f6c2cfbe --- /dev/null +++ b/backend/tests/services/git/test_git_sync.py @@ -0,0 +1,288 @@ +# #region Test.Git.Sync [C:3] [TYPE Module] [SEMANTICS test,git,sync,push,pull,remote] +# @BRIEF Tests for GitServiceSyncMixin — push_changes and pull_changes with origin host alignment, error mapping, and merge detection. +# @RELATION BINDS_TO -> [GitServiceSyncMixin] +# @TEST_EDGE: push_no_heads -> warning logged, no push attempted +# @TEST_EDGE: push_no_origin -> HTTPException 400 +# @TEST_EDGE: push_non_fast_forward -> HTTPException 409 +# @TEST_EDGE: push_success -> origin.push called +# @TEST_EDGE: push_no_tracking -> set-upstream push +# @TEST_EDGE: pull_unfinished_merge -> HTTPException 409 +# @TEST_EDGE: pull_no_origin -> HTTPException 400 +# @TEST_EDGE: pull_no_remote_branch -> HTTPException 409 +# @TEST_EDGE: pull_conflict -> HTTPException 409 +# @TEST_EDGE: pull_success -> fetch + pull called + +import contextlib +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import pytest +from unittest.mock import MagicMock, patch, PropertyMock + +from fastapi import HTTPException +from git.exc import GitCommandError + +from src.services.git._sync import GitServiceSyncMixin +from src.services.git._merge import GitServiceMergeMixin + + +class TestableGitSync(GitServiceSyncMixin, GitServiceMergeMixin): + """Concrete test class providing _locked, get_repo, and URL mixin stubs.""" + def __init__(self, mock_repo=None): + self._mock_repo = mock_repo or MagicMock() + + async def get_repo(self, dashboard_id): + return self._mock_repo + + def _locked(self, dashboard_id): + @contextlib.contextmanager + def _lock(): + yield + return _lock() + + def _align_origin_host_with_config(self, **kwargs): + return None + + +# ── push_changes ── + +class TestPushChanges: + """push_changes — push local commits to origin.""" + + # #region test_push_no_heads [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_push_no_heads(self): + """No local branches → returns early, no push.""" + repo = MagicMock() + repo.heads = [] + svc = TestableGitSync(repo) + with patch("src.services.git._sync.SessionLocal"): + result = await svc.push_changes(1) + assert result is None + # #endregion test_push_no_heads + + # #region test_push_no_origin [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_push_no_origin(self): + """No origin remote → HTTPException 400.""" + repo = MagicMock() + repo.heads = [MagicMock()] + repo.remote.side_effect = ValueError("no remote") + svc = TestableGitSync(repo) + with patch("src.services.git._sync.SessionLocal"): + with pytest.raises(HTTPException, match="origin"): + await svc.push_changes(1) + # #endregion test_push_no_origin + + # #region test_push_success_with_tracking [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_push_success_with_tracking(self): + """Branch with tracking → origin.push called.""" + repo = MagicMock() + repo.heads = [MagicMock()] + branch = MagicMock() + branch.name = "dev" + branch.tracking_branch.return_value = MagicMock() + repo.active_branch = branch + origin = MagicMock() + origin.urls = ["https://gitea.com/org/repo.git"] + origin.push.return_value = [] + repo.remote.return_value = origin + svc = TestableGitSync(repo) + with patch("src.services.git._sync.SessionLocal") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.query.return_value.filter.return_value.first.return_value = None + await svc.push_changes(1) + origin.push.assert_called_once() + # #endregion test_push_success_with_tracking + + # #region test_push_no_tracking_set_upstream [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_push_no_tracking_set_upstream(self): + """No tracking branch → --set-upstream push.""" + repo = MagicMock() + repo.heads = [MagicMock()] + branch = MagicMock() + branch.name = "feature" + branch.tracking_branch.return_value = None + repo.active_branch = branch + origin = MagicMock() + origin.urls = ["https://gitea.com/org/repo.git"] + repo.remote.return_value = origin + svc = TestableGitSync(repo) + with patch("src.services.git._sync.SessionLocal") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.query.return_value.filter.return_value.first.return_value = None + await svc.push_changes(1) + repo.git.push.assert_called_with("--set-upstream", "origin", "feature:feature") + # #endregion test_push_no_tracking_set_upstream + + # #region test_push_non_fast_forward [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_push_non_fast_forward(self): + """Non-fast-forward rejection → HTTPException 409.""" + repo = MagicMock() + repo.heads = [MagicMock()] + branch = MagicMock() + branch.name = "dev" + branch.tracking_branch.return_value = MagicMock() + repo.active_branch = branch + origin = MagicMock() + origin.urls = ["https://gitea.com/org/repo.git"] + origin.push.side_effect = GitCommandError("push", "rejected non-fast-forward") + repo.remote.return_value = origin + svc = TestableGitSync(repo) + with patch("src.services.git._sync.SessionLocal") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.query.return_value.filter.return_value.first.return_value = None + with pytest.raises(HTTPException) as exc_info: + await svc.push_changes(1) + assert exc_info.value.status_code == 409 + # #endregion test_push_non_fast_forward + + # #region test_push_error_flags [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_push_error_flags(self): + """Push info with ERROR flag → raises Exception.""" + repo = MagicMock() + repo.heads = [MagicMock()] + branch = MagicMock() + branch.name = "dev" + branch.tracking_branch.return_value = MagicMock() + repo.active_branch = branch + origin = MagicMock() + origin.urls = ["https://gitea.com/org/repo.git"] + push_info = MagicMock() + push_info.flags = push_info.ERROR + push_info.remote_ref_string = "refs/heads/dev" + push_info.summary = "remote error" + origin.push.return_value = [push_info] + repo.remote.return_value = origin + svc = TestableGitSync(repo) + with patch("src.services.git._sync.SessionLocal") as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.query.return_value.filter.return_value.first.return_value = None + with pytest.raises(HTTPException, match="push failed"): + await svc.push_changes(1) + # #endregion test_push_error_flags + + +# ── pull_changes ── + +class TestPullChanges: + """pull_changes — pull from origin with merge detection.""" + + # #region test_pull_unfinished_merge [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pull_unfinished_merge(self): + """MERGE_HEAD exists → HTTPException 409.""" + repo = MagicMock() + repo.git_dir = "/tmp/repo/.git" + repo.active_branch.name = "dev" + repo.working_tree_dir = "/tmp/repo" + repo.index.unmerged_blobs.return_value = {} + svc = TestableGitSync(repo) + with patch("os.path.exists", return_value=True), \ + patch("pathlib.Path.read_text", return_value="merge_head_sha"): + with pytest.raises(HTTPException) as exc_info: + await svc.pull_changes(1) + assert exc_info.value.status_code == 409 + # #endregion test_pull_unfinished_merge + + # #region test_pull_no_origin [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pull_no_origin(self): + """No origin remote → HTTPException 400.""" + repo = MagicMock() + repo.git_dir = "/tmp/repo/.git" + repo.remote.side_effect = ValueError("no remote") + svc = TestableGitSync(repo) + with patch("os.path.exists", return_value=False): + with pytest.raises(HTTPException, match="origin"): + await svc.pull_changes(1) + # #endregion test_pull_no_origin + + # #region test_pull_no_remote_branch [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pull_no_remote_branch(self): + """Remote branch doesn't exist → HTTPException 409.""" + repo = MagicMock() + repo.git_dir = "/tmp/repo/.git" + repo.active_branch.name = "feature" + origin = MagicMock() + origin.urls = ["https://gitea.com/org/repo.git"] + repo.remote.return_value = origin + repo.refs = [] # no origin/feature ref + svc = TestableGitSync(repo) + with patch("os.path.exists", return_value=False): + with pytest.raises(HTTPException, match="does not exist"): + await svc.pull_changes(1) + # #endregion test_pull_no_remote_branch + + # #region test_pull_success [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pull_success(self): + """Successful pull → fetch + pull called.""" + repo = MagicMock() + repo.git_dir = "/tmp/repo/.git" + repo.active_branch.name = "dev" + origin = MagicMock() + origin.urls = ["https://gitea.com/org/repo.git"] + repo.remote.return_value = origin + remote_ref = MagicMock() + remote_ref.name = "origin/dev" + repo.refs = [remote_ref] + svc = TestableGitSync(repo) + with patch("os.path.exists", return_value=False): + await svc.pull_changes(1) + origin.fetch.assert_called_once_with(prune=True) + repo.git.pull.assert_called_with("--no-rebase", "origin", "dev") + # #endregion test_pull_success + + # #region test_pull_conflict [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pull_conflict(self): + """Merge conflict during pull → HTTPException 409.""" + repo = MagicMock() + repo.git_dir = "/tmp/repo/.git" + repo.active_branch.name = "dev" + origin = MagicMock() + origin.urls = ["https://gitea.com/org/repo.git"] + repo.remote.return_value = origin + remote_ref = MagicMock() + remote_ref.name = "origin/dev" + repo.refs = [remote_ref] + repo.git.pull.side_effect = GitCommandError("pull", "CONFLICT content") + svc = TestableGitSync(repo) + with patch("os.path.exists", return_value=False): + with pytest.raises(HTTPException) as exc_info: + await svc.pull_changes(1) + assert exc_info.value.status_code == 409 + # #endregion test_pull_conflict + + # #region test_pull_generic_error [C:2] [TYPE Function] + @pytest.mark.asyncio + async def test_pull_generic_error(self): + """Generic exception → HTTPException 500.""" + repo = MagicMock() + repo.git_dir = "/tmp/repo/.git" + repo.active_branch.name = "dev" + origin = MagicMock() + origin.urls = ["https://gitea.com/org/repo.git"] + repo.remote.return_value = origin + remote_ref = MagicMock() + remote_ref.name = "origin/dev" + repo.refs = [remote_ref] + repo.git.pull.side_effect = Exception("network failure") + svc = TestableGitSync(repo) + with patch("os.path.exists", return_value=False): + with pytest.raises(HTTPException) as exc_info: + await svc.pull_changes(1) + assert exc_info.value.status_code == 500 + # #endregion test_pull_generic_error +# #endregion Test.Git.Sync diff --git a/backend/tests/services/git/test_git_url.py b/backend/tests/services/git/test_git_url.py new file mode 100644 index 00000000..7292cb92 --- /dev/null +++ b/backend/tests/services/git/test_git_url.py @@ -0,0 +1,347 @@ +# #region Test.Git.Url [C:3] [TYPE Module] [SEMANTICS test,git,url,parse,remote] +# @BRIEF Tests for GitServiceUrlMixin — host extraction, credential stripping, host replacement, origin alignment, remote identity parsing, server URL derivation, URL normalization. +# @RELATION BINDS_TO -> [GitServiceUrlMixin] +# @TEST_EDGE: empty_url -> returns None or empty +# @TEST_EDGE: non_http_scheme -> returns None (ssh/git protocol) +# @TEST_EDGE: url_with_port -> host:port preserved +# @TEST_EDGE: url_with_credentials -> credentials stripped/replaced correctly +# @TEST_EDGE: ssh_url -> parsed via git@ split +# @TEST_EDGE: missing_owner_repo -> raises HTTPException 400 +# @TEST_EDGE: empty_server_url -> raises HTTPException 400 + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src")) + +import pytest +from unittest.mock import MagicMock, patch + +from fastapi import HTTPException + +from src.services.git._url import GitServiceUrlMixin + + +class TestableGitUrl(GitServiceUrlMixin): + """Minimal concrete subclass for testing URL mixin methods.""" + pass + + +# ── _extract_http_host ── + +class TestExtractHttpHost: + """_extract_http_host — host[:port] extraction from HTTP(S) URLs.""" + + # #region test_extract_host_https [C:2] [TYPE Function] + def test_extract_host_https(self): + """Standard HTTPS URL returns lowercase host.""" + svc = TestableGitUrl() + result = svc._extract_http_host("https://Git.Example.COM/org/repo.git") + assert result == "git.example.com" + # #endregion test_extract_host_https + + # #region test_extract_host_with_port [C:2] [TYPE Function] + def test_extract_host_with_port(self): + """URL with port returns host:port.""" + svc = TestableGitUrl() + result = svc._extract_http_host("http://gitea.local:3000/repo") + assert result == "gitea.local:3000" + # #endregion test_extract_host_with_port + + # #region test_extract_host_empty [C:2] [TYPE Function] + def test_extract_host_empty(self): + """Empty/None input returns None.""" + svc = TestableGitUrl() + assert svc._extract_http_host(None) is None + assert svc._extract_http_host("") is None + assert svc._extract_http_host(" ") is None + # #endregion test_extract_host_empty + + # #region test_extract_host_non_http [C:2] [TYPE Function] + def test_extract_host_non_http(self): + """SSH or FTP scheme returns None.""" + svc = TestableGitUrl() + assert svc._extract_http_host("git@github.com:org/repo.git") is None + assert svc._extract_http_host("ftp://files.example.com/repo") is None + # #endregion test_extract_host_non_http + + # #region test_extract_host_no_hostname [C:2] [TYPE Function] + def test_extract_host_no_hostname(self): + """Malformed URL with no hostname returns None.""" + svc = TestableGitUrl() + assert svc._extract_http_host("http://") is None + # #endregion test_extract_host_no_hostname + + +# ── _strip_url_credentials ── + +class TestStripUrlCredentials: + """_strip_url_credentials — remove user:pass from URL.""" + + # #region test_strip_credentials [C:2] [TYPE Function] + def test_strip_credentials(self): + """Credentials removed, scheme/host/path preserved.""" + svc = TestableGitUrl() + result = svc._strip_url_credentials("https://oauth2:token123@git.example.com/org/repo.git") + assert result == "https://git.example.com/org/repo.git" + # #endregion test_strip_credentials + + # #region test_strip_no_credentials [C:2] [TYPE Function] + def test_strip_no_credentials(self): + """URL without credentials returned unchanged.""" + svc = TestableGitUrl() + result = svc._strip_url_credentials("https://git.example.com/org/repo.git") + assert result == "https://git.example.com/org/repo.git" + # #endregion test_strip_no_credentials + + # #region test_strip_empty [C:2] [TYPE Function] + def test_strip_empty(self): + """Empty string returned as-is.""" + svc = TestableGitUrl() + assert svc._strip_url_credentials("") == "" + # #endregion test_strip_empty + + # #region test_strip_non_http_passthrough [C:2] [TYPE Function] + def test_strip_non_http_passthrough(self): + """Non-HTTP URL returned unchanged.""" + svc = TestableGitUrl() + url = "git@github.com:org/repo.git" + assert svc._strip_url_credentials(url) == url + # #endregion test_strip_non_http_passthrough + + # #region test_strip_with_port [C:2] [TYPE Function] + def test_strip_with_port(self): + """Port preserved after credential stripping.""" + svc = TestableGitUrl() + result = svc._strip_url_credentials("https://user:pass@gitea.local:3000/repo") + assert result == "https://gitea.local:3000/repo" + # #endregion test_strip_with_port + + +# ── _replace_host_in_url ── + +class TestReplaceHostInUrl: + """_replace_host_in_url — swap origin host with config host.""" + + # #region test_replace_host_basic [C:2] [TYPE Function] + def test_replace_host_basic(self): + """Source host replaced with config host, credentials preserved.""" + svc = TestableGitUrl() + result = svc._replace_host_in_url( + "https://old-host.com/org/repo.git", + "https://new-host.com", + ) + assert result == "https://new-host.com/org/repo.git" + # #endregion test_replace_host_basic + + # #region test_replace_host_preserves_auth [C:2] [TYPE Function] + def test_replace_host_preserves_auth(self): + """Credentials from source URL preserved on new host.""" + svc = TestableGitUrl() + result = svc._replace_host_in_url( + "https://oauth2:tok123@old-host.com/org/repo.git", + "https://new-host.com:3000", + ) + assert "oauth2:tok123@new-host.com:3000" in result + assert "/org/repo.git" in result + # #endregion test_replace_host_preserves_auth + + # #region test_replace_host_empty_inputs [C:2] [TYPE Function] + def test_replace_host_empty_inputs(self): + """Empty source or config returns None.""" + svc = TestableGitUrl() + assert svc._replace_host_in_url(None, "https://x.com") is None + assert svc._replace_host_in_url("https://x.com", None) is None + assert svc._replace_host_in_url("", "") is None + # #endregion test_replace_host_empty_inputs + + # #region test_replace_host_non_http [C:2] [TYPE Function] + def test_replace_host_non_http(self): + """Non-HTTP source or config returns None.""" + svc = TestableGitUrl() + assert svc._replace_host_in_url("git@old:x.git", "https://new.com") is None + assert svc._replace_host_in_url("https://old.com/x", "ftp://new.com") is None + # #endregion test_replace_host_non_http + + +# ── _parse_remote_repo_identity ── + +class TestParseRemoteRepoIdentity: + """_parse_remote_repo_identity — extract owner/repo from remote URL.""" + + # #region test_parse_https_url [C:2] [TYPE Function] + def test_parse_https_url(self): + """HTTPS URL parsed into owner, repo, namespace.""" + svc = TestableGitUrl() + result = svc._parse_remote_repo_identity("https://gitea.example.com/org/repo.git") + assert result["owner"] == "org" + assert result["repo"] == "repo" + assert result["namespace"] == "org" + assert result["full_name"] == "org/repo" + # #endregion test_parse_https_url + + # #region test_parse_ssh_url [C:2] [TYPE Function] + def test_parse_ssh_url(self): + """SSH git@ URL parsed via colon split.""" + svc = TestableGitUrl() + result = svc._parse_remote_repo_identity("git@github.com:myorg/myrepo.git") + assert result["owner"] == "myorg" + assert result["repo"] == "myrepo" + assert result["namespace"] == "myorg" + # #endregion test_parse_ssh_url + + # #region test_parse_nested_namespace [C:2] [TYPE Function] + def test_parse_nested_namespace(self): + """Nested namespace (subgroups) preserved.""" + svc = TestableGitUrl() + result = svc._parse_remote_repo_identity("https://gitlab.com/group/subgroup/project.git") + assert result["owner"] == "group" + assert result["repo"] == "project" + assert result["namespace"] == "group/subgroup" + assert result["full_name"] == "group/subgroup/project" + # #endregion test_parse_nested_namespace + + # #region test_parse_empty_url_raises [C:2] [TYPE Function] + def test_parse_empty_url_raises(self): + """Empty URL raises HTTPException 400.""" + svc = TestableGitUrl() + with pytest.raises(HTTPException, match="empty"): + svc._parse_remote_repo_identity("") + # #endregion test_parse_empty_url_raises + + # #region test_parse_single_segment_raises [C:2] [TYPE Function] + def test_parse_single_segment_raises(self): + """URL with <2 path segments raises HTTPException 400.""" + svc = TestableGitUrl() + with pytest.raises(HTTPException, match="Cannot parse"): + svc._parse_remote_repo_identity("https://host.com/onlyone") + # #endregion test_parse_single_segment_raises + + # #region test_parse_no_git_suffix [C:2] [TYPE Function] + def test_parse_no_git_suffix(self): + """URL without .git suffix parsed correctly.""" + svc = TestableGitUrl() + result = svc._parse_remote_repo_identity("https://host.com/owner/repo") + assert result["repo"] == "repo" + # #endregion test_parse_no_git_suffix + + +# ── _derive_server_url_from_remote ── + +class TestDeriveServerUrlFromRemote: + """_derive_server_url_from_remote — build API base URL.""" + + # #region test_derive_https [C:2] [TYPE Function] + def test_derive_https(self): + """HTTPS URL → scheme://host base.""" + svc = TestableGitUrl() + result = svc._derive_server_url_from_remote("https://gitea.example.com/org/repo.git") + assert result == "https://gitea.example.com" + # #endregion test_derive_https + + # #region test_derive_with_port [C:2] [TYPE Function] + def test_derive_with_port(self): + """Port preserved in derived URL.""" + svc = TestableGitUrl() + result = svc._derive_server_url_from_remote("http://gitea.local:3000/repo") + assert result == "http://gitea.local:3000" + # #endregion test_derive_with_port + + # #region test_derive_ssh_returns_none [C:2] [TYPE Function] + def test_derive_ssh_returns_none(self): + """SSH URL returns None.""" + svc = TestableGitUrl() + assert svc._derive_server_url_from_remote("git@host:org/repo.git") is None + # #endregion test_derive_ssh_returns_none + + # #region test_derive_empty_returns_none [C:2] [TYPE Function] + def test_derive_empty_returns_none(self): + """Empty input returns None.""" + svc = TestableGitUrl() + assert svc._derive_server_url_from_remote("") is None + assert svc._derive_server_url_from_remote(None) is None + # #endregion test_derive_empty_returns_none + + +# ── _normalize_git_server_url ── + +class TestNormalizeGitServerUrl: + """_normalize_git_server_url — strip trailing slash.""" + + # #region test_normalize_strips_slash [C:2] [TYPE Function] + def test_normalize_strips_slash(self): + """Trailing slash removed.""" + svc = TestableGitUrl() + assert svc._normalize_git_server_url("https://gitea.com/") == "https://gitea.com" + # #endregion test_normalize_strips_slash + + # #region test_normalize_no_change [C:2] [TYPE Function] + def test_normalize_no_change(self): + """URL without trailing slash unchanged.""" + svc = TestableGitUrl() + assert svc._normalize_git_server_url("https://gitea.com") == "https://gitea.com" + # #endregion test_normalize_no_change + + # #region test_normalize_empty_raises [C:2] [TYPE Function] + def test_normalize_empty_raises(self): + """Empty URL raises HTTPException 400.""" + svc = TestableGitUrl() + with pytest.raises(HTTPException, match="required"): + svc._normalize_git_server_url("") + # #endregion test_normalize_empty_raises + + +# ── _align_origin_host_with_config ── + +class TestAlignOriginHostWithConfig: + """_align_origin_host_with_config — auto-align origin host drift.""" + + # #region test_align_no_drift [C:2] [TYPE Function] + def test_align_no_drift(self): + """Matching hosts → no action, returns None.""" + svc = TestableGitUrl() + origin = MagicMock() + result = svc._align_origin_host_with_config( + dashboard_id=1, + origin=origin, + config_url="https://gitea.com", + current_origin_url="https://gitea.com/org/repo.git", + binding_remote_url=None, + ) + assert result is None + origin.set_url.assert_not_called() + # #endregion test_align_no_drift + + # #region test_align_host_mismatch [C:2] [TYPE Function] + @patch("src.services.git._url.SessionLocal") + def test_align_host_mismatch(self, mock_session_cls): + """Mismatched hosts → origin.set_url called, DB updated.""" + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_session.query.return_value.filter.return_value.first.return_value = None + + svc = TestableGitUrl() + origin = MagicMock() + result = svc._align_origin_host_with_config( + dashboard_id=1, + origin=origin, + config_url="https://new-host.com", + current_origin_url="https://old-host.com/org/repo.git", + binding_remote_url=None, + ) + assert result is not None + assert "new-host.com" in result + origin.set_url.assert_called_once() + # #endregion test_align_host_mismatch + + # #region test_align_missing_config [C:2] [TYPE Function] + def test_align_missing_config(self): + """No config URL → returns None, no action.""" + svc = TestableGitUrl() + origin = MagicMock() + result = svc._align_origin_host_with_config( + dashboard_id=1, origin=origin, config_url=None, + current_origin_url="https://host.com/repo", binding_remote_url=None, + ) + assert result is None + # #endregion test_align_missing_config +# #endregion Test.Git.Url diff --git a/backend/tests/services/test_auth_service.py b/backend/tests/services/test_auth_service.py new file mode 100644 index 00000000..38693074 --- /dev/null +++ b/backend/tests/services/test_auth_service.py @@ -0,0 +1,289 @@ +# #region Test.AuthService [C:3] [TYPE Module] [SEMANTICS test,auth,service] +# @BRIEF Verify AuthService contracts — authenticate_user, create_session, provision_adfs_user. +# @RELATION BINDS_TO -> [AuthService] +# @TEST_EDGE: user_not_found -> authenticate_user returns None +# @TEST_EDGE: user_inactive -> authenticate_user returns None +# @TEST_EDGE: wrong_password -> authenticate_user returns None +# @TEST_EDGE: adfs_new_user -> provision_adfs_user creates User and commits +# @TEST_EDGE: adfs_existing_user -> provision_adfs_user syncs roles without creating +# @TEST_EDGE: adfs_no_upn_fallback_email -> username derived from email when upn absent + +from pathlib import Path +import sys +from contextlib import contextmanager +from unittest.mock import MagicMock, call, patch + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) + +import pytest + + +# ── Helpers (C1 — anchor pair only) ── + +# #region _make_user +def _make_user(**kwargs): + defaults = { + "id": "user-001", + "username": "alice", + "email": "alice@example.com", + "password_hash": "$2b$12$FAKEHASH", + "is_active": True, + "roles": [], + "last_login": None, + } + defaults.update(kwargs) + user = MagicMock() + for k, v in defaults.items(): + setattr(user, k, v) + return user +# #endregion _make_user + +# #region _make_role +def _make_role(name: str): + role = MagicMock() + role.name = name + return role +# #endregion _make_role + +# #region _noop_belief_scope +@contextmanager +def _noop_belief_scope(_id): + yield +# #endregion _noop_belief_scope + + +# ── Fixtures ── + +@pytest.fixture() +def mock_db(): + db = MagicMock() + db.commit = MagicMock() + db.refresh = MagicMock() + db.add = MagicMock() + return db + + +# ════════════════════════════════════════════════════════════════ +# authenticate_user +# ════════════════════════════════════════════════════════════════ + +class TestAuthenticateUser: + """authenticate_user — credential validation and account-state gating.""" + + # #region test_authenticate_user_success + # @BRIEF Active user with correct password returns User and updates last_login. + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.verify_password", return_value=True) + @patch("src.services.auth_service.AuthRepository") + def test_authenticate_user_success(self, MockRepo, _vp, _bs, mock_db): + from src.services.auth_service import AuthService + + expected_user = _make_user(username="alice", is_active=True) + instance = MockRepo.return_value + instance.get_user_by_username.return_value = expected_user + + svc = AuthService(mock_db) + result = svc.authenticate_user("alice", "correct-password") + + assert result is expected_user + assert result.last_login is not None + mock_db.commit.assert_called_once() + mock_db.refresh.assert_called_once_with(expected_user) + # #endregion test_authenticate_user_success + + # #region test_authenticate_user_not_found + # @BRIEF Returns None when username does not exist in the database. + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.verify_password") + @patch("src.services.auth_service.AuthRepository") + def test_authenticate_user_not_found(self, MockRepo, mock_vp, _bs, mock_db): + from src.services.auth_service import AuthService + + MockRepo.return_value.get_user_by_username.return_value = None + + svc = AuthService(mock_db) + result = svc.authenticate_user("ghost", "pw") + + assert result is None + mock_vp.assert_not_called() + # #endregion test_authenticate_user_not_found + + # #region test_authenticate_user_inactive + # @BRIEF Returns None when user exists but is_active is False. + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.verify_password") + @patch("src.services.auth_service.AuthRepository") + def test_authenticate_user_inactive(self, MockRepo, mock_vp, _bs, mock_db): + from src.services.auth_service import AuthService + + inactive_user = _make_user(is_active=False) + MockRepo.return_value.get_user_by_username.return_value = inactive_user + + svc = AuthService(mock_db) + result = svc.authenticate_user("alice", "pw") + + assert result is None + mock_vp.assert_not_called() + # #endregion test_authenticate_user_inactive + + # #region test_authenticate_user_wrong_password + # @BRIEF Returns None when password hash verification fails. + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.verify_password", return_value=False) + @patch("src.services.auth_service.AuthRepository") + def test_authenticate_user_wrong_password(self, MockRepo, _vp, _bs, mock_db): + from src.services.auth_service import AuthService + + active_user = _make_user(is_active=True) + MockRepo.return_value.get_user_by_username.return_value = active_user + + svc = AuthService(mock_db) + result = svc.authenticate_user("alice", "wrong-password") + + assert result is None + mock_db.commit.assert_not_called() + # #endregion test_authenticate_user_wrong_password + + +# ════════════════════════════════════════════════════════════════ +# create_session +# ════════════════════════════════════════════════════════════════ + +class TestCreateSession: + """create_session — JWT issuance with role-based scopes.""" + + # #region test_create_session_returns_bearer_token + # @BRIEF Returns dict with access_token and token_type='bearer'. + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.create_access_token", return_value="jwt-token-abc") + @patch("src.services.auth_service.AuthRepository") + def test_create_session_returns_bearer_token(self, MockRepo, mock_jwt, _bs, mock_db): + from src.services.auth_service import AuthService + + role_admin = _make_role("admin") + role_editor = _make_role("editor") + user = _make_user(username="alice", roles=[role_admin, role_editor]) + + svc = AuthService(mock_db) + session = svc.create_session(user) + + assert session == {"access_token": "jwt-token-abc", "token_type": "bearer"} + mock_jwt.assert_called_once_with( + data={"sub": "alice", "scopes": ["admin", "editor"]} + ) + # #endregion test_create_session_returns_bearer_token + + # #region test_create_session_no_roles + # @BRIEF User with no roles produces empty scopes list. + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.create_access_token", return_value="jwt-empty") + @patch("src.services.auth_service.AuthRepository") + def test_create_session_no_roles(self, MockRepo, mock_jwt, _bs, mock_db): + from src.services.auth_service import AuthService + + user = _make_user(username="bob", roles=[]) + + svc = AuthService(mock_db) + session = svc.create_session(user) + + assert session["token_type"] == "bearer" + mock_jwt.assert_called_once_with(data={"sub": "bob", "scopes": []}) + # #endregion test_create_session_no_roles + + +# ════════════════════════════════════════════════════════════════ +# provision_adfs_user +# ════════════════════════════════════════════════════════════════ + +class TestProvisionAdfsUser: + """provision_adfs_user — JIT provisioning and role synchronization.""" + + # #region test_provision_new_adfs_user + # @BRIEF Creates new User when username not found, sets ADFS fields, commits. + @patch("src.services.auth_service.log_security_event") + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.AuthRepository") + def test_provision_new_adfs_user(self, MockRepo, _bs, mock_log, mock_db): + from src.services.auth_service import AuthService + + MockRepo.return_value.get_user_by_username.return_value = None + mapped_role = _make_role("ad-users") + MockRepo.return_value.get_roles_by_ad_groups.return_value = [mapped_role] + + svc = AuthService(mock_db) + user_info = {"upn": "carol@corp.com", "email": "carol@corp.com", "name": "Carol", "groups": ["AD-Users"]} + result = svc.provision_adfs_user(user_info) + + mock_db.add.assert_called_once() + new_user = mock_db.add.call_args[0][0] + assert new_user.username == "carol@corp.com" + assert new_user.auth_source == "ADFS" + assert new_user.is_ad_user is True + assert new_user.is_active is True + assert new_user.roles == [mapped_role] + mock_db.commit.assert_called_once() + mock_db.refresh.assert_called_once() + mock_log.assert_called_once_with("USER_PROVISIONED", "carol@corp.com", {"source": "ADFS"}) + # #endregion test_provision_new_adfs_user + + # #region test_provision_existing_adfs_user + # @BRIEF Existing user gets roles synced without creating a new record. + @patch("src.services.auth_service.log_security_event") + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.AuthRepository") + def test_provision_existing_adfs_user(self, MockRepo, _bs, mock_log, mock_db): + from src.services.auth_service import AuthService + + existing = _make_user(username="dave@corp.com") + MockRepo.return_value.get_user_by_username.return_value = existing + role1 = _make_role("analysts") + MockRepo.return_value.get_roles_by_ad_groups.return_value = [role1] + + svc = AuthService(mock_db) + result = svc.provision_adfs_user({"upn": "dave@corp.com", "groups": ["AD-Analysts"]}) + + mock_db.add.assert_not_called() + mock_log.assert_not_called() + assert existing.roles == [role1] + assert existing.last_login is not None + mock_db.commit.assert_called_once() + # #endregion test_provision_existing_adfs_user + + # #region test_provision_adfs_user_email_fallback + # @BRIEF Uses 'email' claim as username when 'upn' is absent. + @patch("src.services.auth_service.log_security_event") + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.AuthRepository") + def test_provision_adfs_user_email_fallback(self, MockRepo, _bs, mock_log, mock_db): + from src.services.auth_service import AuthService + + MockRepo.return_value.get_user_by_username.return_value = None + MockRepo.return_value.get_roles_by_ad_groups.return_value = [] + + svc = AuthService(mock_db) + result = svc.provision_adfs_user({"email": "eve@corp.com", "groups": []}) + + new_user = mock_db.add.call_args[0][0] + assert new_user.username == "eve@corp.com" + # #endregion test_provision_adfs_user_email_fallback + + # #region test_provision_adfs_user_no_groups + # @BRIEF Empty groups list results in empty roles and no error. + @patch("src.services.auth_service.log_security_event") + @patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope) + @patch("src.services.auth_service.AuthRepository") + def test_provision_adfs_user_no_groups(self, MockRepo, _bs, mock_log, mock_db): + from src.services.auth_service import AuthService + + existing = _make_user(username="frank@corp.com") + MockRepo.return_value.get_user_by_username.return_value = existing + MockRepo.return_value.get_roles_by_ad_groups.return_value = [] + + svc = AuthService(mock_db) + result = svc.provision_adfs_user({"upn": "frank@corp.com"}) + + assert existing.roles == [] + mock_db.commit.assert_called_once() + # #endregion test_provision_adfs_user_no_groups + +# #endregion Test.AuthService diff --git a/backend/tests/services/test_profile_utils.py b/backend/tests/services/test_profile_utils.py index b0b37624..189d1db4 100644 --- a/backend/tests/services/test_profile_utils.py +++ b/backend/tests/services/test_profile_utils.py @@ -1,6 +1,9 @@ # #region Test.ProfileUtils [C:3] [TYPE Module] [SEMANTICS test,profile,utils,pure-functions] -# @BRIEF Tests for profile_utils.py — sanitize, normalize, mask, validate payload, build default preference, owner tokens. +# @BRIEF Tests for profile_utils.py — sanitize, normalize, mask, validate, default preference, owner tokens, exceptions. # @RELATION BINDS_TO -> [ProfileUtils] +# @TEST_EDGE: missing_field -> None/empty inputs handled gracefully +# @TEST_EDGE: invalid_type -> Invalid values produce validation errors +# @TEST_EDGE: external_fail -> N/A (pure functions, no external deps) from pathlib import Path import sys @@ -9,365 +12,330 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) import pytest +from src.services.profile_utils import ( + EnvironmentNotFoundError, + ProfileAuthorizationError, + ProfileValidationError, + build_default_preference, + mask_secret_value, + normalize_density, + normalize_owner_tokens, + normalize_start_page, + normalize_username, + sanitize_secret, + sanitize_text, + sanitize_username, + validate_update_payload, +) + + +def _validate(**overrides): + defaults = dict( + superset_username="admin", + show_only_my_dashboards=False, + git_email=None, + start_page="dashboards", + dashboards_table_density="comfortable", + ) + defaults.update(overrides) + return validate_update_payload(**defaults) + + +# --- sanitize_text --- + +# #region test_sanitize_text [C:2] [TYPE Function] +# @BRIEF Verify sanitize_text with None, empty, whitespace, and valid inputs. +@pytest.mark.parametrize("value, expected", [ + (None, None), ("", None), (" ", None), + (" hello ", "hello"), ("world", "world"), +]) +def test_sanitize_text(value, expected): + assert sanitize_text(value) == expected +# #endregion test_sanitize_text + +# --- sanitize_secret --- + +# #region test_sanitize_secret [C:2] [TYPE Function] +# @BRIEF Verify sanitize_secret with None, empty, whitespace, and valid secrets. +@pytest.mark.parametrize("value, expected", [ + (None, None), ("", None), (" ", None), + (" my-token-abc ", "my-token-abc"), +]) +def test_sanitize_secret(value, expected): + assert sanitize_secret(value) == expected +# #endregion test_sanitize_secret + +# --- sanitize_username --- + +# #region test_sanitize_username [C:2] [TYPE Function] +# @BRIEF Verify sanitize_username delegates to sanitize_text. +@pytest.mark.parametrize("value, expected", [ + (None, None), (" admin ", "admin"), +]) +def test_sanitize_username(value, expected): + assert sanitize_username(value) == expected +# #endregion test_sanitize_username + +# --- normalize_username --- + +# #region test_normalize_username [C:2] [TYPE Function] +# @BRIEF Verify trim+lower normalization for actor matching. +@pytest.mark.parametrize("value, expected", [ + (None, None), ("", None), + (" AdminUser ", "adminuser"), ("guest", "guest"), +]) +def test_normalize_username(value, expected): + assert normalize_username(value) == expected +# #endregion test_normalize_username + +# --- normalize_start_page --- + +# #region test_normalize_start_page [C:2] [TYPE Function] +# @BRIEF Verify start page alias mapping to canonical values. +@pytest.mark.parametrize("value, expected", [ + ("reports-logs", "reports"), ("dashboards", "dashboards"), + ("datasets", "datasets"), ("reports", "reports"), + ("settings", "dashboards"), (None, "dashboards"), + ("DATASETS", "datasets"), +]) +def test_normalize_start_page(value, expected): + assert normalize_start_page(value) == expected +# #endregion test_normalize_start_page + +# --- normalize_density --- + +# #region test_normalize_density [C:2] [TYPE Function] +# @BRIEF Verify density alias mapping to canonical values. +@pytest.mark.parametrize("value, expected", [ + ("free", "comfortable"), ("compact", "compact"), + ("comfortable", "comfortable"), ("spacious", "comfortable"), + (None, "comfortable"), +]) +def test_normalize_density(value, expected): + assert normalize_density(value) == expected +# #endregion test_normalize_density + +# --- mask_secret_value --- + +# #region test_mask_secret_value [C:2] [TYPE Function] +# @BRIEF Verify safe display masking for secrets of various lengths. +@pytest.mark.parametrize("value, expected", [ + (None, None), ("", None), + ("ab", "***"), ("abcd", "***"), + ("my-token-value", "my***ue"), (" ab ", "***"), +]) +def test_mask_secret_value(value, expected): + assert mask_secret_value(value) == expected +# #endregion test_mask_secret_value + +# --- validate_update_payload --- + +# #region test_validate_valid_payload [C:2] [TYPE Function] +# @BRIEF All-valid input returns empty error list. +def test_validate_valid_payload(): + assert _validate() == [] +# #endregion test_validate_valid_payload + +# #region test_validate_username_with_space [C:2] [TYPE Function] +# @BRIEF Username containing space produces validation error. +def test_validate_username_with_space(): + errors = _validate(superset_username="admin user") + assert any("space" in e.lower() for e in errors) +# #endregion test_validate_username_with_space + +# #region test_validate_show_only_requires_username [C:2] [TYPE Function] +# @BRIEF show_only_my_dashboards=True without username produces error. +def test_validate_show_only_requires_username(): + errors = _validate(superset_username=None, show_only_my_dashboards=True) + assert any("username" in e.lower() and "required" in e.lower() for e in errors) +# #endregion test_validate_show_only_requires_username + +# #region test_validate_git_email_missing_at [C:2] [TYPE Function] +# @BRIEF Git email without @ produces validation error. +def test_validate_git_email_missing_at(): + errors = _validate(git_email="not-an-email") + assert any("git email" in e.lower() for e in errors) +# #endregion test_validate_git_email_missing_at + +# #region test_validate_git_email_with_space [C:2] [TYPE Function] +# @BRIEF Git email containing space produces validation error. +def test_validate_git_email_with_space(): + errors = _validate(git_email="admin @x.com") + assert any("git email" in e.lower() for e in errors) +# #endregion test_validate_git_email_with_space + +# #region test_validate_git_email_starts_with_at [C:2] [TYPE Function] +# @BRIEF Git email starting with @ produces validation error. +def test_validate_git_email_starts_with_at(): + errors = _validate(git_email="@example.com") + assert any("git email" in e.lower() for e in errors) +# #endregion test_validate_git_email_starts_with_at + +# #region test_validate_git_email_ends_with_at [C:2] [TYPE Function] +# @BRIEF Git email ending with @ produces validation error. +def test_validate_git_email_ends_with_at(): + errors = _validate(git_email="admin@") + assert any("git email" in e.lower() for e in errors) +# #endregion test_validate_git_email_ends_with_at + +# #region test_validate_invalid_start_page [C:2] [TYPE Function] +# @BRIEF Unsupported start_page value produces validation error. +def test_validate_invalid_start_page(): + errors = _validate(start_page="settings") + assert any("start page" in e.lower() for e in errors) +# #endregion test_validate_invalid_start_page + +# #region test_validate_invalid_density [C:2] [TYPE Function] +# @BRIEF Unsupported density value produces validation error. +def test_validate_invalid_density(): + errors = _validate(dashboards_table_density="spacious") + assert any("density" in e.lower() for e in errors) +# #endregion test_validate_invalid_density + +# #region test_validate_invalid_notification_email [C:2] [TYPE Function] +# @BRIEF Invalid notification email variants all produce validation error. +@pytest.mark.parametrize("bad_email", ["bad-email", "@x.com", "a@", "a b@c.com"]) +def test_validate_invalid_notification_email(bad_email): + errors = _validate(email_address=bad_email) + assert any("notification email" in e.lower() for e in errors) +# #endregion test_validate_invalid_notification_email + +# #region test_validate_valid_notification_email [C:2] [TYPE Function] +# @BRIEF Valid notification email produces no notification error. +def test_validate_valid_notification_email(): + errors = _validate(email_address="user@example.com") + assert not any("notification email" in e.lower() for e in errors) +# #endregion test_validate_valid_notification_email + +# #region test_validate_multiple_errors [C:2] [TYPE Function] +# @BRIEF Multiple invalid fields produce multiple accumulated errors. +def test_validate_multiple_errors(): + errors = _validate( + superset_username="admin user", + show_only_my_dashboards=True, + git_email="bad", + start_page="unknown", + dashboards_table_density="spacious", + ) + assert len(errors) >= 4 +# #endregion test_validate_multiple_errors + +# --- build_default_preference --- + +# #region test_build_default_preference [C:2] [TYPE Function] +# @BRIEF Verify default preference DTO has correct initial values for all fields. +def test_build_default_preference(): + pref = build_default_preference("user-1") + assert pref.user_id == "user-1" + assert pref.superset_username is None + assert pref.superset_username_normalized is None + assert pref.show_only_my_dashboards is False + assert pref.show_only_slug_dashboards is True + assert pref.git_username is None + assert pref.git_email is None + assert pref.has_git_personal_access_token is False + assert pref.git_personal_access_token_masked is None + assert pref.start_page == "dashboards" + assert pref.auto_open_task_drawer is True + assert pref.dashboards_table_density == "comfortable" + assert pref.telegram_id is None + assert pref.email_address is None + assert pref.notify_on_fail is True + assert pref.created_at == pref.updated_at +# #endregion test_build_default_preference + +# --- normalize_owner_tokens --- + +# #region test_owner_tokens_none [C:2] [TYPE Function] +# @BRIEF None input returns empty list. +def test_owner_tokens_none(): + assert normalize_owner_tokens(None) == [] +# #endregion test_owner_tokens_none + +# #region test_owner_tokens_empty [C:2] [TYPE Function] +# @BRIEF Empty iterable returns empty list. +def test_owner_tokens_empty(): + assert normalize_owner_tokens([]) == [] +# #endregion test_owner_tokens_empty + +# #region test_owner_tokens_dict_username [C:2] [TYPE Function] +# @BRIEF Dict with username field produces normalized token. +def test_owner_tokens_dict_username(): + tokens = normalize_owner_tokens([{"username": "AdminUser"}]) + assert "adminuser" in tokens +# #endregion test_owner_tokens_dict_username + +# #region test_owner_tokens_dict_names [C:2] [TYPE Function] +# @BRIEF Dict with first/last name produces individual and combined tokens. +def test_owner_tokens_dict_names(): + tokens = normalize_owner_tokens([{"first_name": "John", "last_name": "Doe"}]) + assert "john" in tokens + assert "doe" in tokens + assert "john_doe" in tokens +# #endregion test_owner_tokens_dict_names + +# #region test_owner_tokens_string [C:2] [TYPE Function] +# @BRIEF Plain string items are normalized directly. +def test_owner_tokens_string(): + tokens = normalize_owner_tokens([" Admin "]) + assert "admin" in tokens +# #endregion test_owner_tokens_string + +# #region test_owner_tokens_dedup [C:2] [TYPE Function] +# @BRIEF Duplicate tokens after normalization are collapsed. +def test_owner_tokens_dedup(): + tokens = normalize_owner_tokens(["admin", " Admin "]) + assert tokens == ["admin"] +# #endregion test_owner_tokens_dedup + +# #region test_owner_tokens_skip_empty [C:2] [TYPE Function] +# @BRIEF Empty/whitespace/None items produce no tokens. +def test_owner_tokens_skip_empty(): + assert normalize_owner_tokens(["", " ", None]) == [] +# #endregion test_owner_tokens_skip_empty + +# #region test_owner_tokens_dict_email [C:2] [TYPE Function] +# @BRIEF Dict with email field produces normalized email token. +def test_owner_tokens_dict_email(): + tokens = normalize_owner_tokens([{"email": "User@Example.com"}]) + assert "user@example.com" in tokens +# #endregion test_owner_tokens_dict_email + +# #region test_owner_tokens_dict_multi [C:2] [TYPE Function] +# @BRIEF Dict with multiple fields produces all valid candidate tokens. +def test_owner_tokens_dict_multi(): + owners = [{"username": "jdoe", "first_name": "John", "last_name": "Doe", "email": "john@example.com"}] + tokens = normalize_owner_tokens(owners) + assert "jdoe" in tokens + assert "john" in tokens + assert "doe" in tokens + assert "john@example.com" in tokens +# #endregion test_owner_tokens_dict_multi + +# --- exception classes --- + +# #region test_exception_profile_validation_error [C:2] [TYPE Function] +# @BRIEF ProfileValidationError stores errors list and has expected message. +def test_exception_profile_validation_error(): + err = ProfileValidationError(["field1 bad", "field2 bad"]) + assert err.errors == ["field1 bad", "field2 bad"] + assert "validation failed" in str(err).lower() + with pytest.raises(ProfileValidationError): + raise ProfileValidationError(["test"]) +# #endregion test_exception_profile_validation_error + +# #region test_exception_environment_not_found [C:2] [TYPE Function] +# @BRIEF EnvironmentNotFoundError can be raised and caught as Exception. +def test_exception_environment_not_found(): + with pytest.raises(EnvironmentNotFoundError): + raise EnvironmentNotFoundError("env-xyz") +# #endregion test_exception_environment_not_found + +# #region test_exception_profile_authorization [C:2] [TYPE Function] +# @BRIEF ProfileAuthorizationError can be raised and caught as Exception. +def test_exception_profile_authorization(): + with pytest.raises(ProfileAuthorizationError): + raise ProfileAuthorizationError("cross-user") +# #endregion test_exception_profile_authorization -class TestSanitizeText: - """sanitize_text — normalize optional text into trimmed form or None.""" - - def test_none_returns_none(self): - from src.services.profile_utils import sanitize_text - assert sanitize_text(None) is None - - def test_empty_returns_none(self): - from src.services.profile_utils import sanitize_text - assert sanitize_text("") is None - - def test_whitespace_returns_none(self): - from src.services.profile_utils import sanitize_text - assert sanitize_text(" ") is None - - def test_trimmed_text(self): - from src.services.profile_utils import sanitize_text - assert sanitize_text(" hello ") == "hello" - - def test_already_trimmed(self): - from src.services.profile_utils import sanitize_text - assert sanitize_text("world") == "world" - - -class TestSanitizeSecret: - """sanitize_secret — normalize secret value into trimmed form or None.""" - - def test_none_returns_none(self): - from src.services.profile_utils import sanitize_secret - assert sanitize_secret(None) is None - - def test_empty_returns_none(self): - from src.services.profile_utils import sanitize_secret - assert sanitize_secret("") is None - - def test_whitespace_returns_none(self): - from src.services.profile_utils import sanitize_secret - assert sanitize_secret(" ") is None - - def test_trimmed_secret(self): - from src.services.profile_utils import sanitize_secret - assert sanitize_secret(" my-token-abc ") == "my-token-abc" - - -class TestSanitizeUsername: - """sanitize_username — delegates to sanitize_text.""" - - def test_none_returns_none(self): - from src.services.profile_utils import sanitize_username - assert sanitize_username(None) is None - - def test_valid_username(self): - from src.services.profile_utils import sanitize_username - assert sanitize_username(" admin ") == "admin" - - -class TestNormalizeUsername: - """normalize_username — trim + lower.""" - - def test_none_returns_none(self): - from src.services.profile_utils import normalize_username - assert normalize_username(None) is None - - def test_empty_returns_none(self): - from src.services.profile_utils import normalize_username - assert normalize_username("") is None - - def test_trim_and_lower(self): - from src.services.profile_utils import normalize_username - assert normalize_username(" AdminUser ") == "adminuser" - - def test_already_normalized(self): - from src.services.profile_utils import normalize_username - assert normalize_username("guest") == "guest" - - -class TestNormalizeStartPage: - """normalize_start_page — map aliases to canonical values.""" - - def test_reports_logs_maps_to_reports(self): - from src.services.profile_utils import normalize_start_page - assert normalize_start_page("reports-logs") == "reports" - - def test_dashboards_passes_through(self): - from src.services.profile_utils import normalize_start_page - assert normalize_start_page("dashboards") == "dashboards" - - def test_datasets_passes_through(self): - from src.services.profile_utils import normalize_start_page - assert normalize_start_page("datasets") == "datasets" - - def test_reports_passes_through(self): - from src.services.profile_utils import normalize_start_page - assert normalize_start_page("reports") == "reports" - - def test_unknown_defaults_to_dashboards(self): - from src.services.profile_utils import normalize_start_page - assert normalize_start_page("settings") == "dashboards" - - def test_none_defaults_to_dashboards(self): - from src.services.profile_utils import normalize_start_page - assert normalize_start_page(None) == "dashboards" - - def test_case_insensitive(self): - from src.services.profile_utils import normalize_start_page - assert normalize_start_page("DATASETS") == "datasets" - - -class TestNormalizeDensity: - """normalize_density — map aliases to canonical values.""" - - def test_free_maps_to_comfortable(self): - from src.services.profile_utils import normalize_density - assert normalize_density("free") == "comfortable" - - def test_compact_passes_through(self): - from src.services.profile_utils import normalize_density - assert normalize_density("compact") == "compact" - - def test_comfortable_passes_through(self): - from src.services.profile_utils import normalize_density - assert normalize_density("comfortable") == "comfortable" - - def test_unknown_defaults_to_comfortable(self): - from src.services.profile_utils import normalize_density - assert normalize_density("spacious") == "comfortable" - - def test_none_defaults_to_comfortable(self): - from src.services.profile_utils import normalize_density - assert normalize_density(None) == "comfortable" - - -class TestMaskSecretValue: - """mask_secret_value — safe display value for secrets.""" - - def test_none_returns_none(self): - from src.services.profile_utils import mask_secret_value - assert mask_secret_value(None) is None - - def test_empty_returns_none(self): - from src.services.profile_utils import mask_secret_value - assert mask_secret_value("") is None - - def test_short_secret_returns_asterisks(self): - from src.services.profile_utils import mask_secret_value - assert mask_secret_value("ab") == "***" - - def test_exactly_four_chars_returns_asterisks(self): - from src.services.profile_utils import mask_secret_value - assert mask_secret_value("abcd") == "***" - - def test_longer_secret_masked(self): - from src.services.profile_utils import mask_secret_value - assert mask_secret_value("my-token-value") == "my***ue" - - def test_whitespace_stripped_before_masking(self): - from src.services.profile_utils import mask_secret_value - assert mask_secret_value(" ab ") == "***" - - -class TestValidateUpdatePayload: - """validate_update_payload — field-level validation for preference mutation.""" - - def test_valid_payload_returns_empty(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin", - show_only_my_dashboards=False, - git_email="admin@example.com", - start_page="dashboards", - dashboards_table_density="comfortable", - ) - assert errors == [] - - def test_username_with_spaces(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin user", - show_only_my_dashboards=False, - git_email=None, - start_page="dashboards", - dashboards_table_density="comfortable", - ) - assert any("space" in e.lower() for e in errors) - - def test_show_only_requires_username_when_true(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username=None, - show_only_my_dashboards=True, - git_email=None, - start_page="dashboards", - dashboards_table_density="comfortable", - ) - assert any("username" in e.lower() and "required" in e.lower() for e in errors) - - def test_invalid_git_email(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin", - show_only_my_dashboards=False, - git_email="not-an-email", - start_page="dashboards", - dashboards_table_density="comfortable", - ) - assert any("email" in e.lower() for e in errors) - - def test_git_email_with_spaces(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin", - show_only_my_dashboards=False, - git_email="admin @x.com", - start_page="dashboards", - dashboards_table_density="comfortable", - ) - assert any("email" in e.lower() for e in errors) - - def test_git_email_starts_with_at(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin", - show_only_my_dashboards=False, - git_email="@example.com", - start_page="dashboards", - dashboards_table_density="comfortable", - ) - assert any("email" in e.lower() for e in errors) - - def test_invalid_start_page(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin", - show_only_my_dashboards=False, - git_email=None, - start_page="settings", - dashboards_table_density="comfortable", - ) - assert any("start page" in e.lower() for e in errors) - - def test_invalid_density(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin", - show_only_my_dashboards=False, - git_email=None, - start_page="dashboards", - dashboards_table_density="spacious", - ) - assert any("density" in e.lower() for e in errors) - - def test_invalid_notification_email(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin", - show_only_my_dashboards=False, - git_email=None, - start_page="dashboards", - dashboards_table_density="comfortable", - email_address="bad-email", - ) - assert any("notification email" in e.lower() for e in errors) - - def test_valid_notification_email(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin", - show_only_my_dashboards=False, - git_email=None, - start_page="dashboards", - dashboards_table_density="comfortable", - email_address="user@example.com", - ) - assert not any("notification email" in e.lower() for e in errors) - - def test_multiple_errors(self): - from src.services.profile_utils import validate_update_payload - errors = validate_update_payload( - superset_username="admin user", - show_only_my_dashboards=True, - git_email="bad", - start_page="unknown", - dashboards_table_density="spacious", - ) - assert len(errors) >= 4 - - -class TestBuildDefaultPreference: - """build_default_preference — default ProfilePreference for unconfigured users.""" - - def test_returns_profile_preference(self): - from src.services.profile_utils import build_default_preference - result = build_default_preference("user-1") - assert result.user_id == "user-1" - assert result.superset_username is None - assert result.show_only_my_dashboards is False - assert result.show_only_slug_dashboards is True - assert result.start_page == "dashboards" - assert result.dashboards_table_density == "comfortable" - assert result.has_git_personal_access_token is False - assert result.notify_on_fail is True - - -class TestNormalizeOwnerTokens: - """normalize_owner_tokens — deduplicated lower-cased tokens from owners.""" - - def test_none_returns_empty(self): - from src.services.profile_utils import normalize_owner_tokens - assert normalize_owner_tokens(None) == [] - - def test_empty_list(self): - from src.services.profile_utils import normalize_owner_tokens - assert normalize_owner_tokens([]) == [] - - def test_dict_owner_with_username(self): - from src.services.profile_utils import normalize_owner_tokens - owners = [{"username": "AdminUser"}] - tokens = normalize_owner_tokens(owners) - assert "adminuser" in tokens - - def test_dict_owner_with_full_name(self): - from src.services.profile_utils import normalize_owner_tokens - owners = [{"first_name": "John", "last_name": "Doe"}] - tokens = normalize_owner_tokens(owners) - assert "john" in tokens - assert "doe" in tokens - assert "john_doe" or "john doe" in tokens - - def test_string_owner(self): - from src.services.profile_utils import normalize_owner_tokens - owners = [" Admin "] - tokens = normalize_owner_tokens(owners) - assert "admin" in tokens - - def test_dedup(self): - from src.services.profile_utils import normalize_owner_tokens - owners = ["admin", " Admin "] - tokens = normalize_owner_tokens(owners) - assert tokens == ["admin"] - - def test_skip_empty(self): - from src.services.profile_utils import normalize_owner_tokens - owners = ["", " ", None] - tokens = normalize_owner_tokens(owners) - assert tokens == [] - - def test_dict_owner_with_email(self): - from src.services.profile_utils import normalize_owner_tokens - owners = [{"email": "User@Example.com"}] - tokens = normalize_owner_tokens(owners) - assert "user@example.com" in tokens - - def test_dict_owner_multiple_candidates(self): - from src.services.profile_utils import normalize_owner_tokens - owners = [{"username": "jdoe", "first_name": "John", "last_name": "Doe", "email": "john@example.com"}] - tokens = normalize_owner_tokens(owners) - assert "jdoe" in tokens - assert "john" in tokens - assert "doe" in tokens - assert "john@example.com" in tokens # #endregion Test.ProfileUtils diff --git a/backend/tests/services/test_security_badge_service.py b/backend/tests/services/test_security_badge_service.py index ccc927d5..013d5c3d 100644 --- a/backend/tests/services/test_security_badge_service.py +++ b/backend/tests/services/test_security_badge_service.py @@ -1,6 +1,9 @@ -# #region Test.SecurityBadgeService [C:3] [TYPE Module] [SEMANTICS test,profile,security,permissions,badges] -# @BRIEF Tests for security_badge_service.py — role extraction, permission pairs, security summary. +# #region Test.SecurityBadgeService [C:3] [TYPE Module] [SEMANTICS test,security,badge] +# @BRIEF Verify SecurityBadgeService contracts — role extraction, permission pairs, security summary. # @RELATION BINDS_TO -> [SecurityBadgeService] +# @TEST_EDGE: missing_field -> empty/None roles, permissions, auth_source handled gracefully +# @TEST_EDGE: invalid_type -> empty role names, whitespace-only names sanitized +# @TEST_EDGE: external_fail -> discover_declared_permissions raises, fallback to user permissions from pathlib import Path import sys @@ -10,131 +13,152 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from unittest.mock import MagicMock, patch import pytest - -def make_user(**kwargs): - """Helper to create a mock User with roles.""" +# #region _make_user [C:1] [TYPE Function] +def _make_user(**kwargs): from src.models.auth import User user = MagicMock(spec=User) for k, v in kwargs.items(): setattr(user, k, v) return user - - -def make_role(name, permissions=None): + # #endregion _make_user +# #region _make_role [C:1] [TYPE Function] +def _make_role(name, permissions=None): role = MagicMock() role.name = name role.permissions = permissions or [] return role - - -def make_permission(resource, action): + # #endregion _make_role +# #region _make_permission [C:1] [TYPE Function] +def _make_permission(resource, action): perm = MagicMock() perm.resource = resource perm.action = action return perm +# #endregion _make_permission class TestCollectUserPermissionPairs: """_collect_user_permission_pairs — extract (resource, ACTION) tuples.""" - + # #region test_no_roles_returns_empty [C:2] [TYPE Function] + # @BRIEF User with empty roles list yields no permission pairs. def test_no_roles_returns_empty(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - user = make_user(roles=[]) + user = _make_user(roles=[]) result = svc._collect_user_permission_pairs(user) assert result == set() + # #endregion test_no_roles_returns_empty + # #region test_role_with_permissions [C:2] [TYPE Function] + # @BRIEF Role permissions are normalized to (resource, ACTION) tuples. def test_role_with_permissions(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - perms = [make_permission("dashboard", "read"), make_permission("dataset", "write")] - role = make_role("Editor", perms) - user = make_user(roles=[role]) + perms = [_make_permission("dashboard", "read"), _make_permission("dataset", "write")] + role = _make_role("Editor", perms) + user = _make_user(roles=[role]) result = svc._collect_user_permission_pairs(user) assert ("dashboard", "READ") in result assert ("dataset", "WRITE") in result + # #endregion test_role_with_permissions + # #region test_permission_without_resource_skipped [C:2] [TYPE Function] + # @BRIEF Permissions with empty or None resource are excluded from collection. def test_permission_without_resource_skipped(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - perms = [make_permission("", "read"), make_permission(None, "write")] - role = make_role("Viewer", perms) - user = make_user(roles=[role]) + perms = [_make_permission("", "read"), _make_permission(None, "write")] + role = _make_role("Viewer", perms) + user = _make_user(roles=[role]) result = svc._collect_user_permission_pairs(user) assert result == set() + # #endregion test_permission_without_resource_skipped + # #region test_multiple_roles_dedup [C:2] [TYPE Function] + # @BRIEF Duplicate permissions across roles are deduplicated in result set. def test_multiple_roles_dedup(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - p1 = make_permission("dashboard", "read") - role1 = make_role("Admin", [p1]) - role2 = make_role("Editor", [p1]) - user = make_user(roles=[role1, role2]) + p1 = _make_permission("dashboard", "read") + role1 = _make_role("Admin", [p1]) + role2 = _make_role("Editor", [p1]) + user = _make_user(roles=[role1, role2]) result = svc._collect_user_permission_pairs(user) assert len(result) == 1 - + # #endregion test_multiple_roles_dedup class TestBuildSecuritySummary: """build_security_summary — full security snapshot.""" - + # #region test_no_roles [C:2] [TYPE Function] + # @BRIEF User with no roles produces empty roles list and None current_role. def test_no_roles(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - user = make_user(roles=[], auth_source="database") + user = _make_user(roles=[], auth_source="database") summary = svc.build_security_summary(user) assert summary.read_only is True assert summary.roles == [] assert summary.current_role is None assert summary.auth_source == "database" + # #endregion test_no_roles + # #region test_single_role [C:2] [TYPE Function] + # @BRIEF Single non-admin role becomes current_role and appears in sorted roles list. def test_single_role(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - role = make_role("Viewer") - user = make_user(roles=[role], auth_source="ldap") + role = _make_role("Viewer") + user = _make_user(roles=[role], auth_source="ldap") summary = svc.build_security_summary(user) assert summary.roles == ["Viewer"] assert summary.current_role == "Viewer" + # #endregion test_single_role + # #region test_admin_role_detected [C:2] [TYPE Function] + # @BRIEF Admin role is detected and set as current_role. def test_admin_role_detected(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - role = make_role("Admin") - user = make_user(roles=[role], auth_source="database") + role = _make_role("Admin") + user = _make_user(roles=[role], auth_source="database") summary = svc.build_security_summary(user) assert "Admin" in summary.roles assert summary.current_role == "Admin" + # #endregion test_admin_role_detected + # #region test_roles_sorted [C:2] [TYPE Function] + # @BRIEF Roles in summary are deterministically sorted alphabetically. def test_roles_sorted(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - role_z = make_role("ZViewer") - role_a = make_role("AAdmin") - user = make_user(roles=[role_z, role_a]) + role_z = _make_role("ZViewer") + role_a = _make_role("AAdmin") + user = _make_user(roles=[role_z, role_a]) summary = svc.build_security_summary(user) assert summary.roles == ["AAdmin", "ZViewer"] + # #endregion test_roles_sorted + # #region test_discover_permissions_fallback [C:2] [TYPE Function] + # @BRIEF When discover_declared_permissions raises, fallback to user permission pairs. def test_discover_permissions_fallback(self): - """When discover_declared_permissions raises, fallback to user permissions.""" from src.services.security_badge_service import SecurityBadgeService - perms = [make_permission("dashboard", "read")] - role = make_role("Editor", perms) - user = make_user(roles=[role]) - + perms = [_make_permission("dashboard", "read")] + role = _make_role("Editor", perms) + user = _make_user(roles=[role]) with patch("src.services.security_badge_service.discover_declared_permissions", side_effect=RuntimeError("Plugin load failed")): svc = SecurityBadgeService(plugin_loader=MagicMock()) summary = svc.build_security_summary(user) - # Should still produce permission states assert len(summary.permissions) >= 0 + # #endregion test_discover_permissions_fallback + # #region test_is_admin_allows_all [C:2] [TYPE Function] + # @BRIEF Admin user sees all declared permissions as allowed=True. def test_is_admin_allows_all(self): - """Admin user sees all declared permissions as allowed.""" from src.services.security_badge_service import SecurityBadgeService - perms = [make_permission("dashboard", "read")] - role = make_role("Admin", perms) - user = make_user(roles=[role]) - + perms = [_make_permission("dashboard", "read")] + role = _make_role("Admin", perms) + user = _make_user(roles=[role]) with patch("src.services.security_badge_service.discover_declared_permissions", return_value={("dashboard", "READ"), ("dataset", "WRITE")}): svc = SecurityBadgeService() @@ -142,66 +166,134 @@ class TestBuildSecuritySummary: admin_perm = next((p for p in summary.permissions if p.key == "dashboard"), None) assert admin_perm is not None assert admin_perm.allowed is True + # #endregion test_is_admin_allows_all + # #region test_non_admin_permission_allowed_and_denied [C:2] [TYPE Function] + # @BRIEF Non-admin user: owned permissions allowed=True, others allowed=False. + def test_non_admin_permission_allowed_and_denied(self): + from src.services.security_badge_service import SecurityBadgeService + perms = [_make_permission("dashboard", "read")] + role = _make_role("Editor", perms) + user = _make_user(roles=[role], auth_source="LOCAL") + with patch("src.services.security_badge_service.discover_declared_permissions", + return_value={("dashboard", "READ"), ("dataset", "WRITE")}): + svc = SecurityBadgeService() + summary = svc.build_security_summary(user) + perm_map = {p.key: p.allowed for p in summary.permissions} + assert perm_map["dashboard"] is True + assert perm_map["dataset:write"] is False + # #endregion test_non_admin_permission_allowed_and_denied + + # #region test_role_source_equals_auth_source [C:2] [TYPE Function] + # @BRIEF role_source field mirrors auth_source from user. + def test_role_source_equals_auth_source(self): + from src.services.security_badge_service import SecurityBadgeService + svc = SecurityBadgeService() + user = _make_user(roles=[], auth_source="ADFS") + summary = svc.build_security_summary(user) + assert summary.role_source == "ADFS" + # #endregion test_role_source_equals_auth_source + + # #region test_auth_source_none [C:2] [TYPE Function] + # @BRIEF User with None auth_source produces None in summary. + def test_auth_source_none(self): + from src.services.security_badge_service import SecurityBadgeService + svc = SecurityBadgeService() + user = _make_user(roles=[], auth_source=None) + summary = svc.build_security_summary(user) + assert summary.auth_source is None + # #endregion test_auth_source_none + + # #region test_user_roles_none [C:2] [TYPE Function] + # @BRIEF User with roles=None is treated as empty roles list. + def test_user_roles_none(self): + from src.services.security_badge_service import SecurityBadgeService + svc = SecurityBadgeService() + user = _make_user(roles=None, auth_source="LOCAL") + summary = svc.build_security_summary(user) + assert summary.roles == [] + assert summary.current_role is None + # #endregion test_user_roles_none + + # #region test_permission_key_format_read [C:2] [TYPE Function] + # @BRIEF READ action produces resource-only key (no action suffix). def test_permission_key_format_read(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() key = svc._format_permission_key("dashboard", "READ") assert key == "dashboard" + # #endregion test_permission_key_format_read + # #region test_permission_key_format_write [C:2] [TYPE Function] + # @BRIEF Non-READ action produces resource:action key format. def test_permission_key_format_write(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() key = svc._format_permission_key("dataset", "WRITE") assert key == "dataset:write" - + # #endregion test_permission_key_format_write class TestFormatPermissionKey: """_format_permission_key — compact UI key format.""" - + # #region test_read_omits_action [C:2] [TYPE Function] + # @BRIEF READ action yields bare resource string without action suffix. def test_read_omits_action(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() assert svc._format_permission_key("dashboard", "READ") == "dashboard" + # #endregion test_read_omits_action + # #region test_write_includes_action [C:2] [TYPE Function] + # @BRIEF Non-READ action yields resource:lowercase_action compound key. def test_write_includes_action(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() assert svc._format_permission_key("dataset", "WRITE") == "dataset:write" + # #endregion test_write_includes_action + # #region test_empty_resource [C:2] [TYPE Function] + # @BRIEF Empty resource produces a valid string key without raising. def test_empty_resource(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() result = svc._format_permission_key("", "READ") assert result is not None assert isinstance(result, str) - + # #endregion test_empty_resource class TestPermissionEdgeCases: """Edge cases for permission processing.""" - + # #region test_empty_role_name_skipped [C:2] [TYPE Function] + # @BRIEF Roles with empty or None names are excluded from summary roles list. def test_empty_role_name_skipped(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - role_empty = make_role("") - role_none = make_role(None) - user = make_user(roles=[role_empty, role_none]) + role_empty = _make_role("") + role_none = _make_role(None) + user = _make_user(roles=[role_empty, role_none]) summary = svc.build_security_summary(user) assert summary.roles == [] + # #endregion test_empty_role_name_skipped + # #region test_role_name_with_whitespace_sanitized [C:2] [TYPE Function] + # @BRIEF Role names with surrounding whitespace are trimmed in summary. def test_role_name_with_whitespace_sanitized(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - role = make_role(" Editor ") - user = make_user(roles=[role]) + role = _make_role(" Editor ") + user = _make_user(roles=[role]) summary = svc.build_security_summary(user) assert "Editor" in summary.roles + # #endregion test_role_name_with_whitespace_sanitized + # #region test_collect_with_none_permissions [C:2] [TYPE Function] + # @BRIEF Role with permissions=None yields empty permission set without error. def test_collect_with_none_permissions(self): from src.services.security_badge_service import SecurityBadgeService svc = SecurityBadgeService() - role = make_role("Viewer", permissions=None) - user = make_user(roles=[role]) + role = _make_role("Viewer", permissions=None) + user = _make_user(roles=[role]) result = svc._collect_user_permission_pairs(user) assert result == set() + # #endregion test_collect_with_none_permissions # #endregion Test.SecurityBadgeService diff --git a/backend/tests/services/test_superset_lookup_service.py b/backend/tests/services/test_superset_lookup_service.py index 623200a9..1af2ea1f 100644 --- a/backend/tests/services/test_superset_lookup_service.py +++ b/backend/tests/services/test_superset_lookup_service.py @@ -1,6 +1,10 @@ -# #region Test.SupersetLookupService [C:3] [TYPE Module] [SEMANTICS test,superset,account,lookup,environment] -# @BRIEF Tests for superset_lookup_service.py — resolve environment, lookup accounts, degradation fallback. +# #region Test.SupersetLookupService [C:3] [TYPE Module] [SEMANTICS test,superset,lookup] +# @BRIEF Verify SupersetLookupService — environment resolution, success lookup, degradation fallback, sort normalization. # @RELATION BINDS_TO -> [SupersetLookupService] +# @TEST_EDGE: environment_not_found -> raises EnvironmentNotFoundError +# @TEST_EDGE: external_fail -> returns degraded response with warning +# @TEST_EDGE: invalid_sort_column -> falls back to "username" +# @TEST_EDGE: invalid_sort_order -> falls back to "desc" from pathlib import Path import sys @@ -13,10 +17,27 @@ import pytest from src.schemas.profile import SupersetAccountLookupRequest -class MockEnvironment: +class _Env: def __init__(self, env_id): self.id = env_id - self.name = f"Env-{env_id}" + + +def _make_request(**overrides): + defaults = dict( + environment_id="env-1", search=None, page_index=0, + page_size=20, sort_column="username", sort_order="desc", + ) + defaults.update(overrides) + req = MagicMock(spec=SupersetAccountLookupRequest) + for k, v in defaults.items(): + setattr(req, k, v) + return req + + +def _wire_client(mock_client_cls): + client = MagicMock() + client.client = MagicMock() + mock_client_cls.return_value = client class TestSupersetLookupService: @@ -25,10 +46,7 @@ class TestSupersetLookupService: @pytest.fixture def config_manager(self): cm = MagicMock() - cm.get_environments.return_value = [ - MockEnvironment("env-1"), - MockEnvironment("env-2"), - ] + cm.get_environments.return_value = [_Env("env-1"), _Env("env-2")] return cm @pytest.fixture @@ -36,191 +54,112 @@ class TestSupersetLookupService: from src.services.superset_lookup_service import SupersetLookupService return SupersetLookupService(config_manager) - def test_init(self, config_manager): + # #region test_init_stores_config [C:2] [TYPE Function] + # @BRIEF Constructor stores config_manager reference. + def test_init_stores_config(self, config_manager): from src.services.superset_lookup_service import SupersetLookupService svc = SupersetLookupService(config_manager) assert svc.config_manager is config_manager + # #endregion test_init_stores_config - def test_resolve_environment_found(self, service): + # #region test_resolve_env_found [C:2] [TYPE Function] + # @BRIEF Returns environment when id matches. + def test_resolve_env_found(self, service): env = service._resolve_environment("env-1") - assert env is not None - assert env.id == "env-1" + assert env is not None and env.id == "env-1" + # #endregion test_resolve_env_found - def test_resolve_environment_not_found(self, service): - env = service._resolve_environment("non-existent") - assert env is None - - def test_resolve_environment_case_sensitive(self, service): - env = service._resolve_environment("ENV-1") - assert env is None + # #region test_resolve_env_not_found [C:2] [TYPE Function] + # @BRIEF Returns None for unknown environment id. + def test_resolve_env_not_found(self, service): + assert service._resolve_environment("non-existent") is None + # #endregion test_resolve_env_not_found + # #region test_lookup_env_not_found_raises [C:2] [TYPE Function] + # @BRIEF Raises EnvironmentNotFoundError when environment_id is unknown. @pytest.mark.asyncio - async def test_lookup_environment_not_found(self, service): + async def test_lookup_env_not_found_raises(self, service): from src.services.superset_lookup_service import EnvironmentNotFoundError - request = MagicMock(spec=SupersetAccountLookupRequest) - request.environment_id = "non-existent" - request.search = None - request.page_index = 0 - request.page_size = 20 - request.sort_column = "username" - request.sort_order = "desc" - - with pytest.raises(EnvironmentNotFoundError, match="Environment 'non-existent' not found"): - await service.lookup_superset_accounts(MagicMock(), request) + with pytest.raises(EnvironmentNotFoundError, match="non-existent"): + await service.lookup_superset_accounts(MagicMock(), _make_request(environment_id="non-existent")) + # #endregion test_lookup_env_not_found_raises + # #region test_lookup_success [C:2] [TYPE Function] + # @BRIEF Happy path: adapter returns items → success response with candidates. @pytest.mark.asyncio - async def test_lookup_success(self, service, config_manager): - request = MagicMock(spec=SupersetAccountLookupRequest) - request.environment_id = "env-1" - request.search = None - request.page_index = 0 - request.page_size = 20 - request.sort_column = "username" - request.sort_order = "desc" - - current_user = MagicMock() - current_user.id = "user-1" - - mock_adapter = AsyncMock() - mock_adapter.get_users_page.return_value = { - "items": [ - {"environment_id": "env-1", "username": "admin", "display_name": "Admin", "email": "admin@x.com"}, - ], + async def test_lookup_success(self, service): + adapter = AsyncMock() + adapter.get_users_page.return_value = { + "items": [{"environment_id": "env-1", "username": "admin", "display_name": "Admin", "email": "a@x.com"}], "total": 1, } + with patch("src.services.superset_lookup_service.AsyncSupersetClient") as mc, \ + patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter", return_value=adapter): + _wire_client(mc) + resp = await service.lookup_superset_accounts(MagicMock(id="u1"), _make_request()) - with ( - patch("src.services.superset_lookup_service.AsyncSupersetClient") as mock_client_cls, - patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter") as mock_adapter_cls, - ): - mock_client = MagicMock() - mock_client.client = MagicMock() - mock_client_cls.return_value = mock_client - mock_adapter_cls.return_value = mock_adapter - - response = await service.lookup_superset_accounts(current_user, request) - - assert response.status == "success" - assert response.environment_id == "env-1" - assert len(response.items) == 1 - assert response.items[0].username == "admin" - assert response.warning is None + assert resp.status == "success" + assert resp.environment_id == "env-1" + assert resp.total == 1 + assert len(resp.items) == 1 + assert resp.items[0].username == "admin" + assert resp.warning is None + # #endregion test_lookup_success + # #region test_lookup_degraded_on_exception [C:2] [TYPE Function] + # @BRIEF External failure → degraded response with warning, empty items. @pytest.mark.asyncio - async def test_lookup_degraded_on_exception(self, service, config_manager): - request = MagicMock(spec=SupersetAccountLookupRequest) - request.environment_id = "env-1" - request.search = None - request.page_index = 0 - request.page_size = 20 - request.sort_column = "username" - request.sort_order = "desc" + async def test_lookup_degraded_on_exception(self, service): + with patch("src.services.superset_lookup_service.AsyncSupersetClient", side_effect=ConnectionError("down")): + resp = await service.lookup_superset_accounts(MagicMock(id="u1"), _make_request()) - current_user = MagicMock() - current_user.id = "user-1" - - with ( - patch("src.services.superset_lookup_service.AsyncSupersetClient", - side_effect=ConnectionError("Superset unreachable")), - ): - response = await service.lookup_superset_accounts(current_user, request) - - assert response.status == "degraded" - assert response.environment_id == "env-1" - assert len(response.items) == 0 - assert response.warning is not None - assert "Cannot load" in response.warning + assert resp.status == "degraded" + assert resp.total == 0 + assert resp.items == [] + assert "Cannot load" in resp.warning + # #endregion test_lookup_degraded_on_exception + # #region test_sort_normalization [C:2] [TYPE Function] + # @BRIEF Invalid sort_column → "username"; invalid sort_order → "desc"; None → defaults. @pytest.mark.asyncio - async def test_lookup_invalid_sort_column_defaults_username(self, service, config_manager): - request = MagicMock(spec=SupersetAccountLookupRequest) - request.environment_id = "env-1" - request.search = None - request.page_index = 0 - request.page_size = 20 - request.sort_column = "invalid_col" - request.sort_order = "desc" + async def test_sort_normalization(self, service): + adapter = AsyncMock() + adapter.get_users_page.return_value = {"items": [], "total": 0} - current_user = MagicMock() - current_user.id = "user-1" + cases = [ + ({"sort_column": "invalid_col", "sort_order": "desc"}, "username", "desc"), + ({"sort_column": "username", "sort_order": "invalid"}, "username", "desc"), + ({"sort_column": None, "sort_order": None}, "username", "desc"), + ] + for overrides, expected_col, expected_order in cases: + adapter.reset_mock() + with patch("src.services.superset_lookup_service.AsyncSupersetClient") as mc, \ + patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter", return_value=adapter): + _wire_client(mc) + await service.lookup_superset_accounts(MagicMock(id="u1"), _make_request(**overrides)) - mock_adapter = AsyncMock() - mock_adapter.get_users_page.return_value = {"items": [], "total": 0} - - with ( - patch("src.services.superset_lookup_service.AsyncSupersetClient") as mock_client_cls, - patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter") as mock_adapter_cls, - ): - mock_client = MagicMock() - mock_client.client = MagicMock() - mock_client_cls.return_value = mock_client - mock_adapter_cls.return_value = mock_adapter - - response = await service.lookup_superset_accounts(current_user, request) - - assert response.status == "success" - # Should have used default sort column "username" - mock_adapter.get_users_page.assert_called_once() - call_kwargs = mock_adapter.get_users_page.call_args.kwargs or mock_adapter.get_users_page.call_args[1] - # Sort is passed as kwargs — verify - assert call_kwargs.get("sort_column", mock_adapter.get_users_page.call_args.kwargs.get("sort_column")) is None or True + kw = adapter.get_users_page.call_args.kwargs + assert kw["sort_column"] == expected_col, f"Failed for overrides={overrides}" + assert kw["sort_order"] == expected_order, f"Failed for overrides={overrides}" + # #endregion test_sort_normalization + # #region test_success_total_fallback_to_items_len [C:2] [TYPE Function] + # @BRIEF When adapter omits 'total', response total falls back to len(items). @pytest.mark.asyncio - async def test_lookup_invalid_sort_order_defaults_desc(self, service, config_manager): - request = MagicMock(spec=SupersetAccountLookupRequest) - request.environment_id = "env-1" - request.search = None - request.page_index = 0 - request.page_size = 20 - request.sort_column = "username" - request.sort_order = "invalid" + async def test_success_total_fallback_to_items_len(self, service): + adapter = AsyncMock() + adapter.get_users_page.return_value = { + "items": [ + {"environment_id": "env-1", "username": "a"}, + {"environment_id": "env-1", "username": "b"}, + ], + } + with patch("src.services.superset_lookup_service.AsyncSupersetClient") as mc, \ + patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter", return_value=adapter): + _wire_client(mc) + resp = await service.lookup_superset_accounts(MagicMock(id="u1"), _make_request()) - current_user = MagicMock() - current_user.id = "user-1" - - mock_adapter = AsyncMock() - mock_adapter.get_users_page.return_value = {"items": [], "total": 0} - - with ( - patch("src.services.superset_lookup_service.AsyncSupersetClient") as mock_client_cls, - patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter") as mock_adapter_cls, - ): - mock_client = MagicMock() - mock_client.client = MagicMock() - mock_client_cls.return_value = mock_client - mock_adapter_cls.return_value = mock_adapter - - response = await service.lookup_superset_accounts(current_user, request) - - assert response.status == "success" - mock_adapter.get_users_page.assert_called_once() - - @pytest.mark.asyncio - async def test_lookup_empty_sort_defaults(self, service, config_manager): - request = MagicMock(spec=SupersetAccountLookupRequest) - request.environment_id = "env-1" - request.search = None - request.page_index = 0 - request.page_size = 20 - request.sort_column = None - request.sort_order = None - - current_user = MagicMock() - current_user.id = "user-1" - - mock_adapter = AsyncMock() - mock_adapter.get_users_page.return_value = {"items": [], "total": 0} - - with ( - patch("src.services.superset_lookup_service.AsyncSupersetClient") as mock_client_cls, - patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter") as mock_adapter_cls, - ): - mock_client = MagicMock() - mock_client.client = MagicMock() - mock_client_cls.return_value = mock_client - mock_adapter_cls.return_value = mock_adapter - - response = await service.lookup_superset_accounts(current_user, request) - - assert response.status == "success" + assert resp.status == "success" + assert resp.total == 2 + # #endregion test_success_total_fallback_to_items_len # #endregion Test.SupersetLookupService diff --git a/build.sh b/build.sh index ea7a6c3e..be7de8f4 100755 --- a/build.sh +++ b/build.sh @@ -304,7 +304,7 @@ services: LLM_MODEL: \${LLM_MODEL:-gpt-4o} FASTAPI_URL: http://backend:8000 JWT_SECRET: \${JWT_SECRET:-super-secret-key} - SERVICE_TOKEN_SECRET: \${SERVICE_TOKEN_SECRET:-agent-service-secret} + SERVICE_JWT: \${SERVICE_JWT:-agent-service-secret} DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools} GRADIO_SERVER_PORT: 7860 ports: diff --git a/docker-compose.enterprise-clean.yml b/docker-compose.enterprise-clean.yml index 7c6dd3dd..b95c24f2 100644 --- a/docker-compose.enterprise-clean.yml +++ b/docker-compose.enterprise-clean.yml @@ -97,7 +97,7 @@ services: LLM_MODEL: ${LLM_MODEL:-gpt-4o} FASTAPI_URL: http://backend:8000 JWT_SECRET: ${JWT_SECRET:-super-secret-key} - SERVICE_TOKEN_SECRET: ${SERVICE_TOKEN_SECRET:-agent-service-secret} + SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret} DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:?Set POSTGRES_HOST in .env.enterprise-clean}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools} GRADIO_SERVER_PORT: 7860 ports: diff --git a/docker-compose.yml b/docker-compose.yml index a73aec97..34945936 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,7 +60,7 @@ services: LLM_MODEL: ${LLM_MODEL:-gpt-4o} FASTAPI_URL: http://backend:8000 JWT_SECRET: ${JWT_SECRET:-super-secret-key} - SERVICE_TOKEN_SECRET: ${SERVICE_TOKEN_SECRET:-agent-service-secret} + SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret} DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools GRADIO_SERVER_PORT: 7860 ports: diff --git a/docker/Dockerfile.agent b/docker/Dockerfile.agent index 5b7f2cfc..b3cb80b3 100644 --- a/docker/Dockerfile.agent +++ b/docker/Dockerfile.agent @@ -1,4 +1,17 @@ -# Dockerfile.agent — Gradio + LangGraph agent backend +# #region docker.agent.Dockerfile [C:3] [TYPE Module] [SEMANTICS docker,agent,build] +# @BRIEF Agent Dockerfile — Python 3.11 slim + Gradio + LangGraph agent. +# @LAYER Infrastructure +# @RELATION DEPENDS_ON -> [EXT:path:backend/requirements.txt] +# @PRE Docker builder context — корень проекта. backend/ и docker/ доступны. +# @POST Образ с Gradio-агентом, LangGraph, минимальной копией backend/src/. +# @RATIONALE Агенту нужен только src.agent.* и src.core.cot_logger (чистый stdlib). +# Полный трассировщик импортов показал: +# src.agent.app → src.agent.{context,document_parser,langgraph_setup,middleware,tools} +# → src.core.cot_logger (stdlib-only, нет бизнес-логики) +# Ни src.models, ни src.api, ни src.services, ни src.plugins НЕ импортируются. +# @REJECTED COPY backend/ /app/backend/ rejected — нарушает принцип изоляции контейнеров. +# COPY только backend/src/agent/ недостаточно — нужен src.core.cot_logger. + FROM python:3.11-slim WORKDIR /app @@ -7,13 +20,27 @@ WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/* -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt \ - gradio>=5.0 langgraph>=0.2 langchain-core>=0.3 \ - langchain-openai>=0.3 langgraph-checkpoint-postgres pdfplumber +# Python dependencies — используем backend/requirements.txt (содержит httpx, gradio, +# langgraph, langchain-openai, pdfplumber и все транзитивные зависимости) +COPY backend/requirements.txt /app/backend/requirements.txt +RUN pip install --no-cache-dir -r /app/backend/requirements.txt + +# ── Минимальная копия backend/src/ ────────────────────────────── +# Только то, что реально импортирует агент (трассировка 2026-06-14): +# src/__init__.py — пакетный корень src +# src/core/__init__.py — пакетный корень src.core +# src/core/cot_logger.py — разделяемый логгер (stdlib-only) +# src/agent/*.py — сам агент (8 .py-файлов, плоская структура) +# Ничего из api/, models/, schemas/, services/, plugins/ — не копируем. +COPY backend/src/__init__.py /app/backend/src/__init__.py +COPY backend/src/core/__init__.py /app/backend/src/core/__init__.py +COPY backend/src/core/cot_logger.py /app/backend/src/core/cot_logger.py +COPY backend/src/agent/ /app/backend/src/agent/ # Gradio server ENV GRADIO_SERVER_NAME=0.0.0.0 ENV GRADIO_SERVER_PORT=7860 -CMD ["python", "-m", "backend.src.agent.run"] +WORKDIR /app/backend +CMD ["python", "-m", "src.agent.run"] +# #endregion docker.agent.Dockerfile diff --git a/frontend/vitest.coverage.tmp.config.js b/frontend/vitest.coverage.tmp.config.js new file mode 100644 index 00000000..9336e83f --- /dev/null +++ b/frontend/vitest.coverage.tmp.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + test: { + globals: true, + environment: 'jsdom', + coverage: { + provider: 'istanbul', + reporter: ['text', 'lcovonly'], + clean: false, + include: ['src/lib/**/*.{js,ts,svelte}'], + exclude: ['**/__tests__/**', '**/*.test.*', '**/*.spec.*', '**/locales/**', '**/mocks/**'], + }, + }, +});