# #region Test.Api.Mappings [C:3] [TYPE Module] [SEMANTICS test,mappings,routes] # @BRIEF Tests for mappings API routes — get, create, suggest. # @RELATION BINDS_TO -> [Api.Mappings.MappingsApi] import os os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") os.environ.setdefault("DEV_MODE", "true") import sys from types import SimpleNamespace from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI from fastapi.testclient import TestClient _src = str(Path(__file__).resolve().parent.parent.parent / "src") if _src not in sys.path: sys.path.insert(0, _src) def _make_mapping(**kw): m = MagicMock() m.id = kw.get("id", "map-1") m.source_env_id = kw.get("source_env_id", "env-1") m.target_env_id = kw.get("target_env_id", "env-2") m.source_db_uuid = kw.get("source_db_uuid", "src-uuid") m.target_db_uuid = kw.get("target_db_uuid", "tgt-uuid") m.source_db_name = kw.get("source_db_name", "Source DB") m.target_db_name = kw.get("target_db_name", "Target DB") m.engine = kw.get("engine", "postgresql") return m def _make_client(overrides: dict | None = None) -> TestClient: from src.api.routes.mappings import router from src.core.database import get_db from src.dependencies import get_config_manager, get_current_user from src.schemas.auth import User, RoleSchema app = FastAPI() app.include_router(router, prefix="/api/mappings") mock_user = User( id="admin-1", username="admin", email="admin@x.com", auth_source="LOCAL", created_at=__import__("datetime").datetime.now(), roles=[RoleSchema(id="r1", name="Admin", description="", is_admin=True, permissions=[])], ) app.dependency_overrides[get_db] = lambda: MagicMock() app.dependency_overrides[get_current_user] = lambda: mock_user app.dependency_overrides[get_config_manager] = lambda: MagicMock() if overrides: for dep, fn in overrides.items(): app.dependency_overrides[dep] = fn return TestClient(app) # ── get_mappings ── class TestGetMappings: """GET /mappings""" def test_success(self): mock_db = MagicMock() mock_db.query.return_value.all.return_value = [ _make_mapping(id="m1"), _make_mapping(id="m2"), ] from src.core.database import get_db client = _make_client({get_db: lambda: mock_db}) resp = client.get("/api/mappings") assert resp.status_code == 200 data = resp.json() assert len(data) == 2 assert data[0]["id"] == "m1" def test_filter_by_source(self): mock_db = MagicMock() mock_filter = MagicMock() mock_db.query.return_value.filter.return_value = mock_filter mock_filter.filter.return_value = mock_filter mock_filter.all.return_value = [_make_mapping(id="m1")] from src.core.database import get_db client = _make_client({get_db: lambda: mock_db}) resp = client.get("/api/mappings?source_env_id=env-1") assert resp.status_code == 200 def test_filter_by_target(self): mock_db = MagicMock() mock_filter = MagicMock() mock_db.query.return_value.filter.return_value = mock_filter mock_filter.filter.return_value = mock_filter mock_filter.all.return_value = [] from src.core.database import get_db client = _make_client({get_db: lambda: mock_db}) resp = client.get("/api/mappings?target_env_id=env-2") assert resp.status_code == 200 def test_empty(self): mock_db = MagicMock() mock_db.query.return_value.all.return_value = [] from src.core.database import get_db client = _make_client({get_db: lambda: mock_db}) resp = client.get("/api/mappings") assert resp.status_code == 200 assert resp.json() == [] # #region Test.Api.MappingAnalysis [C:4] [TYPE Module] [SEMANTICS test,mappings,analysis,coverage] # @ingroup Test.Api.Mappings # @BRIEF Verify all-pair coverage classification, partial failure isolation, and API-key scope propagation. # @RELATION VERIFIES -> [Mapping.Analysis.Build] # @RELATION VERIFIES -> [Mapping.AnalysisEndpoint] # @TEST_INVARIANT Saved, suggested, and unmapped counts partition current source databases. # @TEST_EDGE One catalog failure marks only affected pairs as error. class TestMappingAnalysis: # #region Test.Api.MappingAnalysis.Classification [C:3] [TYPE Function] [SEMANTICS test,mappings,analysis] # @ingroup Test.Api.Mappings # @BRIEF Saved mappings are excluded before fuzzy suggestions and unmapped counts are calculated. @pytest.mark.asyncio async def test_classifies_saved_suggested_and_unmapped(self): from src.services.mapping_analysis import build_mapping_coverage environments = [ SimpleNamespace(id="env-1", name="Development"), SimpleNamespace(id="env-2", name="Production"), ] config_manager = MagicMock() config_manager.get_environments.return_value = environments saved = _make_mapping( source_env_id="env-1", target_env_id="env-2", source_db_uuid="saved-source", ) mock_db = MagicMock() mock_db.query.return_value.all.return_value = [saved] clients = [] for catalog in ( [ {"uuid": "saved-source", "database_name": "Saved"}, {"uuid": "suggested-source", "database_name": "Sales"}, {"uuid": "unmapped-source", "database_name": "Telemetry"}, ], [{"uuid": "sales-target", "database_name": "Sales"}], ): client = MagicMock() client.get_databases_summary = AsyncMock(return_value=catalog) client.aclose = AsyncMock() clients.append(client) with patch("src.services.mapping_analysis.SupersetClient", side_effect=clients): result = await build_mapping_coverage(config_manager, mock_db) forward = next( pair for pair in result["pairs"] if pair["source_env_id"] == "env-1" and pair["target_env_id"] == "env-2" ) assert forward["saved_count"] == 1 assert forward["unsaved_suggestion_count"] == 1 assert forward["unmapped_count"] == 1 assert forward["status"] == "attention" assert all(client.aclose.await_count == 1 for client in clients) # #endregion Test.Api.MappingAnalysis.Classification # #region Test.Api.MappingAnalysis.PartialFailure [C:3] [TYPE Function] [SEMANTICS test,mappings,analysis,failure] # @ingroup Test.Api.Mappings # @BRIEF A failed catalog does not prevent healthy environment pairs from being analysed. @pytest.mark.asyncio async def test_isolates_catalog_failure(self): from src.services.mapping_analysis import build_mapping_coverage environments = [ SimpleNamespace(id="env-1", name="Development"), SimpleNamespace(id="env-2", name="Preproduction"), SimpleNamespace(id="env-3", name="Production"), ] config_manager = MagicMock() config_manager.get_environments.return_value = environments mock_db = MagicMock() mock_db.query.return_value.all.return_value = [] clients = [] for result in ( [{"uuid": "one", "database_name": "One"}], RuntimeError("Superset unavailable"), [{"uuid": "three", "database_name": "Three"}], ): client = MagicMock() client.get_databases_summary = AsyncMock( side_effect=result if isinstance(result, Exception) else None, return_value=None if isinstance(result, Exception) else result, ) client.aclose = AsyncMock() clients.append(client) with patch("src.services.mapping_analysis.SupersetClient", side_effect=clients): response = await build_mapping_coverage(config_manager, mock_db) healthy = next( pair for pair in response["pairs"] if pair["source_env_id"] == "env-1" and pair["target_env_id"] == "env-3" ) failed = next( pair for pair in response["pairs"] if pair["source_env_id"] == "env-1" and pair["target_env_id"] == "env-2" ) assert healthy["status"] != "error" assert failed["status"] == "error" assert failed["errors"] == {"env-2": "Superset unavailable"} # #endregion Test.Api.MappingAnalysis.PartialFailure # #region Test.Api.MappingAnalysis.ApiKeyScope [C:3] [TYPE Function] [SEMANTICS test,mappings,analysis,auth] # @ingroup Test.Api.Mappings # @BRIEF Environment-scoped API keys pass their scope to the analysis service. def test_passes_api_key_environment_scope(self): mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = MagicMock( environment_id="env-1" ) expected = { "environments": [], "pairs": [], "totals": { "saved": 0, "unsaved_suggestions": 0, "unmapped": 0, "stale": 0, }, } from src.core.database import get_db client = _make_client({get_db: lambda: mock_db}) with ( patch("src.api.routes.mappings.hash_api_key", return_value="hash"), patch( "src.api.routes.mappings.build_mapping_coverage", new=AsyncMock(return_value=expected), ) as build, ): response = client.get( "/api/mappings/analysis", headers={"X-API-Key": "secret"}, ) assert response.status_code == 200 assert response.json() == expected assert build.await_args.kwargs["environment_scope"] == "env-1" # #endregion Test.Api.MappingAnalysis.ApiKeyScope # #endregion Test.Api.MappingAnalysis # ── create_mapping ── class TestCreateMapping: """POST /mappings""" CREATE_PAYLOAD = { "source_env_id": "env-1", "target_env_id": "env-2", "source_db_uuid": "uuid-1", "target_db_uuid": "uuid-2", "source_db_name": "Source DB", "target_db_name": "Target DB", "engine": "postgresql", } @staticmethod def _validated_config_and_clients(): source_env = MagicMock(id="env-1") target_env = MagicMock(id="env-2") config_manager = MagicMock() config_manager.get_environment.side_effect = { "env-1": source_env, "env-2": target_env, }.get source_client = MagicMock() source_client.get_databases_summary = AsyncMock( return_value=[{"uuid": "uuid-1", "database_name": "Source DB"}] ) source_client.aclose = AsyncMock() target_client = MagicMock() target_client.get_databases_summary = AsyncMock( return_value=[{"uuid": "uuid-2", "database_name": "Target DB"}] ) target_client.aclose = AsyncMock() return config_manager, source_client, target_client def test_create_new(self): mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None # Simulate db.refresh setting the id def _refresh(obj): obj.id = "map-new-1" mock_db.refresh.side_effect = _refresh from src.core.database import get_db from src.dependencies import get_config_manager config_manager, source_client, target_client = self._validated_config_and_clients() client = _make_client({ get_db: lambda: mock_db, get_config_manager: lambda: config_manager, }) with patch( "src.api.routes.mappings.AsyncSupersetClient", side_effect=[source_client, target_client], ): resp = client.post("/api/mappings", json=self.CREATE_PAYLOAD) assert resp.status_code == 200 mock_db.add.assert_called_once() mock_db.commit.assert_called_once() def test_update_existing(self): existing = _make_mapping() mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = existing from src.core.database import get_db from src.dependencies import get_config_manager config_manager, source_client, target_client = self._validated_config_and_clients() client = _make_client({ get_db: lambda: mock_db, get_config_manager: lambda: config_manager, }) with patch( "src.api.routes.mappings.AsyncSupersetClient", side_effect=[source_client, target_client], ): resp = client.post("/api/mappings", json=self.CREATE_PAYLOAD) assert resp.status_code == 200 mock_db.add.assert_not_called() mock_db.commit.assert_called_once() assert existing.target_db_uuid == "uuid-2" assert existing.target_db_name == "Target DB" def test_rejects_unknown_environment(self): mock_db = MagicMock() config_manager = MagicMock() config_manager.get_environment.return_value = None from src.core.database import get_db from src.dependencies import get_config_manager client = _make_client({ get_db: lambda: mock_db, get_config_manager: lambda: config_manager, }) resp = client.post("/api/mappings", json=self.CREATE_PAYLOAD) assert resp.status_code == 400 assert resp.json()["detail"] == "Invalid source or target environment" mock_db.add.assert_not_called() def test_rejects_source_database_from_another_environment(self): mock_db = MagicMock() config_manager, source_client, target_client = self._validated_config_and_clients() payload = {**self.CREATE_PAYLOAD, "source_db_uuid": "target-only-uuid"} from src.core.database import get_db from src.dependencies import get_config_manager client = _make_client({ get_db: lambda: mock_db, get_config_manager: lambda: config_manager, }) with patch( "src.api.routes.mappings.AsyncSupersetClient", side_effect=[source_client, target_client], ): resp = client.post("/api/mappings", json=payload) assert resp.status_code == 400 assert "source environment" in resp.json()["detail"] mock_db.add.assert_not_called() def test_rejects_target_database_from_another_environment(self): mock_db = MagicMock() config_manager, source_client, target_client = self._validated_config_and_clients() payload = {**self.CREATE_PAYLOAD, "target_db_uuid": "source-only-uuid"} from src.core.database import get_db from src.dependencies import get_config_manager client = _make_client({ get_db: lambda: mock_db, get_config_manager: lambda: config_manager, }) with patch( "src.api.routes.mappings.AsyncSupersetClient", side_effect=[source_client, target_client], ): resp = client.post("/api/mappings", json=payload) assert resp.status_code == 400 assert "target environment" in resp.json()["detail"] mock_db.add.assert_not_called() # ── suggest_mappings_api ── class TestSuggestMappingsApi: """POST /mappings/suggest""" def test_success(self): mock_config = MagicMock() mock_suggestions = [{"source": "db1", "target": "db2", "score": 0.95}] from src.dependencies import get_config_manager with patch("src.services.mapping_service.MappingService") as MockMappingSvc: instance = MagicMock() instance.get_suggestions = AsyncMock(return_value=mock_suggestions) MockMappingSvc.return_value = instance client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/mappings/suggest", json={ "source_env_id": "env-1", "target_env_id": "env-2", }) assert resp.status_code == 200 assert resp.json() == mock_suggestions def test_error(self): mock_config = MagicMock() from src.dependencies import get_config_manager with patch("src.services.mapping_service.MappingService") as MockMappingSvc: instance = MagicMock() instance.get_suggestions = AsyncMock(side_effect=RuntimeError("suggestion failed")) MockMappingSvc.return_value = instance client = _make_client({get_config_manager: lambda: mock_config}) resp = client.post("/api/mappings/suggest", json={ "source_env_id": "env-1", "target_env_id": "env-2", }) assert resp.status_code == 500 # #region TestApplyDatasetMetadata [C:3] [TYPE Module] [SEMANTICS test,mappings,metadata,apply] # @BRIEF Tests for apply_dataset_metadata PUT endpoint — doc application to datasets. # @TEST_EDGE: missing_env_id -> 400 "env_id is required" # @TEST_EDGE: env_not_found -> 404 "Environment not found" # @TEST_EDGE: success_with_changes -> 200 status=success, dataset updated # @TEST_EDGE: no_changes -> 200 status=no_changes, dataset not updated # @TEST_EDGE: superset_error -> 500 propagated as HTTPException class TestApplyDatasetMetadata: """PUT /mappings/datasets/{id}/metadata""" BASE_PAYLOAD = { "env_id": "env-1", "description": "Dataset description", "columns": {"col_a": "Column A verbose"}, } MOCK_DATASET_RESPONSE = { "result": { "id": 42, "table_name": "test_table", "schema": "public", "database": {"id": 1}, "owners": [{"id": 1}], "columns": [ {"column_name": "col_a", "id": 1, "verbose_name": "Old Name", "type": "string"}, {"column_name": "col_b", "id": 2, "verbose_name": "Col B", "type": "integer"}, ], "metrics": [], "extra": None, } } # #region Test.Api.TestMissingEnvId [C:2] [TYPE Function] # @BRIEF Request without env_id returns 400. def test_missing_env_id(self): mock_config = MagicMock() mock_config.get_environment.return_value = None from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.put("/api/mappings/datasets/42/metadata", json={ "description": "test", "columns": {}, }) assert resp.status_code == 400 assert "env_id" in resp.json()["detail"] # #endregion Test.Api.TestMissingEnvId # #region Test.Api.TestEnvNotFound [C:2] [TYPE Function] # @BRIEF Unknown env_id returns 404. def test_env_not_found(self): mock_config = MagicMock() mock_config.get_environment.return_value = None from src.dependencies import get_config_manager client = _make_client({get_config_manager: lambda: mock_config}) resp = client.put("/api/mappings/datasets/42/metadata", json=self.BASE_PAYLOAD) assert resp.status_code == 404 assert "not found" in resp.json()["detail"].lower() # #endregion Test.Api.TestEnvNotFound # #region Test.Api.TestSuccessWithChanges [C:2] [TYPE Function] # @BRIEF Valid request updates dataset and returns success. def test_success_with_changes(self): mock_config = MagicMock() mock_config.get_environment.return_value = MagicMock() mock_superset_client = AsyncMock() mock_superset_client.authenticate = AsyncMock() mock_superset_client.get_dataset.return_value = self.MOCK_DATASET_RESPONSE mock_superset_client.update_dataset.return_value = {"result": "ok"} from src.dependencies import get_config_manager with patch("src.core.superset_client.SupersetClient", return_value=mock_superset_client): client = _make_client({get_config_manager: lambda: mock_config}) resp = client.put("/api/mappings/datasets/42/metadata", json=self.BASE_PAYLOAD) assert resp.status_code == 200 data = resp.json() assert data["status"] == "success" assert data["dataset_id"] == 42 # Verify update_dataset was called with the description assert mock_superset_client.update_dataset.call_count >= 1 _call_args = mock_superset_client.update_dataset.call_args # update_dataset(dataset_id, data) -> positional args payload = _call_args[0][1] assert payload["description"] == "Dataset description" # col_a verbose_name should be updated col_a = next(c for c in payload["columns"] if c["column_name"] == "col_a") assert col_a["verbose_name"] == "Column A verbose" # col_b should keep its original verbose_name (not in request.columns) col_b = next(c for c in payload["columns"] if c["column_name"] == "col_b") assert col_b["verbose_name"] == "Col B" # #endregion Test.Api.TestSuccessWithChanges # #region Test.Api.TestNoChanges [C:2] [TYPE Function] # @BRIEF When same values are sent, status returns no_changes and update_dataset is NOT called. def test_no_changes(self): mock_config = MagicMock() mock_config.get_environment.return_value = MagicMock() mock_superset_client = AsyncMock() mock_superset_client.authenticate = AsyncMock() mock_superset_client.get_dataset.return_value = self.MOCK_DATASET_RESPONSE from src.dependencies import get_config_manager with patch("src.core.superset_client.SupersetClient", return_value=mock_superset_client): client = _make_client({get_config_manager: lambda: mock_config}) # Send columns that match existing verbose_name values resp = client.put("/api/mappings/datasets/42/metadata", json={ "env_id": "env-1", "columns": {"col_b": "Col B"}, # already matches }) assert resp.status_code == 200 data = resp.json() assert data["status"] == "no_changes" mock_superset_client.update_dataset.assert_not_called() # #endregion Test.Api.TestNoChanges # #region Test.Api.TestSupersetErrorPropagated [C:2] [TYPE Function] # @BRIEF When SupersetClient fails, endpoint returns 500 with error detail. def test_superset_error_propagated(self): mock_config = MagicMock() mock_config.get_environment.return_value = MagicMock() mock_superset_client = AsyncMock() mock_superset_client.authenticate = AsyncMock() mock_superset_client.get_dataset.side_effect = RuntimeError("Superset API unavailable") from src.dependencies import get_config_manager with patch("src.core.superset_client.SupersetClient", return_value=mock_superset_client): client = _make_client({get_config_manager: lambda: mock_config}) resp = client.put("/api/mappings/datasets/42/metadata", json=self.BASE_PAYLOAD) assert resp.status_code == 500 assert "Superset API unavailable" in resp.json()["detail"] # #endregion Test.Api.TestSupersetErrorPropagated # #endregion TestApplyDatasetMetadata # #endregion Test.Api.Mappings