Files
ss-tools/backend/tests/api/test_dashboard_detail_routes.py

539 lines
23 KiB
Python

# #region Test.Api.DashboardDetailRoutes [C:3] [TYPE Module] [SEMANTICS test,dashboard,detail,api]
# @BRIEF Unit tests for dashboard detail routes — db-mappings, detail, tasks history, thumbnail.
# @RELATION BINDS_TO -> [DashboardDetailRoutes]
# @TEST_EDGE: source_env_not_found -> 404
# @TEST_EDGE: target_env_not_found -> 404
# @TEST_EDGE: db_mapping_fail -> 503
# @TEST_EDGE: dashboard_not_found -> 503
# @TEST_EDGE: env_not_found_detail -> 404
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")
import sys
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_mock_env(id: str = "env-1", name: str = "TestEnv"):
env = MagicMock()
env.id = id
env.name = name
env.url = "https://superset.example.com"
env.username = "admin"
env.password = "pass"
env.verify_ssl = True
return env
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.dashboards._detail_routes import router
from src.dependencies import get_config_manager, get_current_user, get_mapping_service, get_task_manager, has_permission
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router)
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="", permissions=[])],
)
defaults = {
get_config_manager: lambda: MagicMock(),
get_mapping_service: lambda: MagicMock(),
get_task_manager: lambda: MagicMock(),
get_current_user: lambda: mock_user,
}
if overrides:
defaults.update(overrides)
for dep, mock_fn in defaults.items():
app.dependency_overrides[dep] = mock_fn
return TestClient(app)
# ── get_database_mappings ──
class TestGetDatabaseMappings:
"""GET /api/dashboards/db-mappings"""
def test_mappings_success(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [
_make_mock_env(id="src-env"),
_make_mock_env(id="tgt-env"),
]
mock_mapping = AsyncMock()
mock_mapping.get_suggestions.return_value = [
{"source_db": "db1", "target_db": "db2", "source_db_uuid": "u1", "target_db_uuid": "u2", "confidence": 0.95},
]
from src.dependencies import get_config_manager, get_mapping_service
client = _make_client({
get_config_manager: lambda: mock_config,
get_mapping_service: lambda: mock_mapping,
})
resp = client.get("/api/dashboards/db-mappings?source_env_id=src-env&target_env_id=tgt-env")
assert resp.status_code == 200
data = resp.json()
assert len(data["mappings"]) == 1
assert data["mappings"][0]["source_db"] == "db1"
def test_mappings_source_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="tgt-env")]
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/db-mappings?source_env_id=src-ghost&target_env_id=tgt-env")
assert resp.status_code == 404
assert "Source environment not found" in resp.text
def test_mappings_target_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="src-env")]
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/db-mappings?source_env_id=src-env&target_env_id=tgt-ghost")
assert resp.status_code == 404
assert "Target environment not found" in resp.text
def test_mappings_service_fail(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [
_make_mock_env(id="src-env"),
_make_mock_env(id="tgt-env"),
]
mock_mapping = AsyncMock()
mock_mapping.get_suggestions.side_effect = Exception("Service unavailable")
from src.dependencies import get_config_manager, get_mapping_service
client = _make_client({
get_config_manager: lambda: mock_config,
get_mapping_service: lambda: mock_mapping,
})
resp = client.get("/api/dashboards/db-mappings?source_env_id=src-env&target_env_id=tgt-env")
assert resp.status_code == 503
assert "Failed to get database mappings" in resp.text
# ── get_dashboard_detail ──
class TestGetDashboardDetail:
"""GET /api/dashboards/{dashboard_ref}"""
def test_detail_success(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_client.get_dashboard_detail.return_value = {
"id": 42,
"title": "Test Dash",
"chart_count": 5,
"dataset_count": 3,
"charts": [{"id": 1, "title": "Chart 1", "viz_type": "line"}],
"datasets": [{"id": 1, "table_name": "sales", "database": "prod"}],
}
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client") as mock_reg:
mock_reg.return_value = AsyncMock()
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42?env_id=env-1")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == 42
def test_detail_env_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = []
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42?env_id=env-ghost")
assert resp.status_code == 404
assert "Environment not found" in resp.text
def test_detail_service_error_503(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboard_detail.side_effect = Exception("Superset API error")
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client") as mock_reg:
mock_reg.return_value = AsyncMock()
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42?env_id=env-1")
assert resp.status_code == 503
assert "Failed to fetch dashboard detail" in resp.text
# ── get_dashboard_tasks_history ──
class TestGetDashboardTasksHistory:
"""GET /api/dashboards/{dashboard_ref}/tasks"""
def test_tasks_history_success(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_task = MagicMock()
mock_task.id = "task-1"
mock_task.plugin_id = "superset-backup"
mock_task.status = "SUCCESS"
mock_task.params = {"dashboards": ["42"], "environment_id": "env-1"}
mock_task.result = {"status": "ok", "summary": "Backup done"}
mock_task.started_at = __import__("datetime").datetime(2024, 1, 1)
mock_task.finished_at = __import__("datetime").datetime(2024, 1, 1, 0, 1)
mock_task_manager = MagicMock()
mock_task_manager.get_all_tasks.return_value = [mock_task]
from src.dependencies import get_config_manager, get_task_manager
client = _make_client({
get_config_manager: lambda: mock_config,
get_task_manager: lambda: mock_task_manager,
})
resp = client.get("/api/dashboards/42/tasks?env_id=env-1")
assert resp.status_code == 200
data = resp.json()
assert data["dashboard_id"] == 42
assert len(data["items"]) == 1
assert data["items"][0]["id"] == "task-1"
def test_tasks_history_empty(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_task_manager = MagicMock()
mock_task_manager.get_all_tasks.return_value = []
from src.dependencies import get_config_manager, get_task_manager
client = _make_client({
get_config_manager: lambda: mock_config,
get_task_manager: lambda: mock_task_manager,
})
resp = client.get("/api/dashboards/42/tasks?env_id=env-1")
assert resp.status_code == 200
assert resp.json()["items"] == []
def test_tasks_history_slug_no_env_id(self):
mock_task_manager = MagicMock()
from src.dependencies import get_config_manager, get_task_manager
client = _make_client({
get_config_manager: lambda: MagicMock(),
get_task_manager: lambda: mock_task_manager,
})
resp = client.get("/api/dashboards/slug-ref/tasks")
assert resp.status_code == 400
assert "env_id is required" in resp.text
def test_tasks_history_env_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = []
from src.dependencies import get_config_manager, get_task_manager
client = _make_client({
get_config_manager: lambda: mock_config,
get_task_manager: lambda: MagicMock(),
})
resp = client.get("/api/dashboards/slug-ref/tasks?env_id=env-ghost")
assert resp.status_code == 404
assert "Environment not found" in resp.text
# ── get_dashboard_thumbnail ──
class TestGetDashboardThumbnail:
"""GET /api/dashboards/{dashboard_ref}/thumbnail"""
def test_thumbnail_success(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_async_client = AsyncMock()
async def _fake_request(method, endpoint, **kwargs):
if "cache_dashboard_screenshot" in endpoint:
return {"result": {"image_url": "/api/v1/dashboard/42/thumbnail/digest-1/"}}
if "raw_response" in kwargs and kwargs["raw_response"]:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.content = b"fake-image-bytes"
return mock_resp
return {}
mock_async_client.request.side_effect = _fake_request
mock_async_client.get_dashboards_page.return_value = (1, [{"id": 42}])
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1")
assert resp.status_code == 200
assert resp.content == b"fake-image-bytes"
def test_thumbnail_env_not_found(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = []
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-ghost")
assert resp.status_code == 404
assert "Environment not found" in resp.text
def test_thumbnail_202_accepted(self):
"""Superset returns 202 when thumbnail is being generated."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_async_client = AsyncMock()
async def _fake_request_202(method, endpoint, **kwargs):
if "cache_dashboard_screenshot" in endpoint:
return {"result": {"image_url": "/api/v1/dashboard/42/thumbnail/digest-1/"}}
if "raw_response" in kwargs and kwargs["raw_response"]:
mock_resp = MagicMock()
mock_resp.status_code = 202
mock_resp.headers = {"Content-Type": "application/json"}
mock_resp.json.return_value = {"message": "Thumbnail is being generated"}
return mock_resp
return {}
mock_async_client.request.side_effect = _fake_request_202
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1&force=true")
assert resp.status_code == 202
def test_thumbnail_dashboard_not_found_404(self):
"""DashboardNotFoundError returns 404."""
from src.core.utils.network import DashboardNotFoundError
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_async_client = AsyncMock()
mock_async_client.request.side_effect = DashboardNotFoundError("Dashboard not found")
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1")
assert resp.status_code == 404
assert "Dashboard thumbnail not found" in resp.text
def test_thumbnail_fallback_to_dashboard_url(self):
"""Fallback when cache_dashboard_screenshot is unavailable."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_async_client = AsyncMock()
async def _fake_fallback_request(method, endpoint, **kwargs):
if "cache_dashboard_screenshot" in endpoint:
return {} # No image_url in response
if endpoint == "/dashboard/42":
return {"result": {"thumbnail_url": "/api/v1/dashboard/42/thumbnail/fallback-digest/"}}
if "raw_response" in kwargs and kwargs["raw_response"]:
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.headers = {"Content-Type": "image/png"}
mock_resp.content = b"fallback-image"
return mock_resp
return {}
mock_async_client.request.side_effect = _fake_fallback_request
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1")
assert resp.status_code == 200
assert resp.content == b"fallback-image"
def test_thumbnail_generic_error_503(self):
"""Generic exception returns 503."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_async_client = AsyncMock()
mock_async_client.request.side_effect = RuntimeError("Unexpected error")
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1")
assert resp.status_code == 503
assert "Failed to fetch dashboard thumbnail" in resp.text
def test_detail_http_exception_re_raised(self):
"""HTTPException from client is re-raised (line 158 except HTTPException)."""
from fastapi import HTTPException as FastAPIHTTPException
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_client.get_dashboard_detail.side_effect = FastAPIHTTPException(status_code=400, detail="Bad request")
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client") as mock_reg:
mock_reg.return_value = AsyncMock()
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42?env_id=env-1")
assert resp.status_code == 400
# ── get_dashboard_tasks_history — slug ref path ──
class TestGetDashboardTasksHistorySlug:
"""GET /api/dashboards/{slug}/tasks with env_id — slug path (lines 202-203, 268)."""
def test_tasks_history_slug_with_env(self):
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_task = MagicMock()
mock_task.id = "task-1"
mock_task.plugin_id = "superset-backup"
mock_task.status = "SUCCESS"
mock_task.params = {"dashboard_ids": [42], "environment_id": "env-1"}
mock_task.result = {"status": "ok", "summary": "Backup done"}
mock_task.started_at = __import__("datetime").datetime(2024, 1, 1)
mock_task.finished_at = __import__("datetime").datetime(2024, 1, 1, 0, 1)
mock_task_manager = MagicMock()
mock_task_manager.get_all_tasks.return_value = [mock_task]
mock_client = AsyncMock()
# _resolve_dashboard_id_from_ref_async calls get_dashboards_page
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
from src.dependencies import get_config_manager, get_task_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client):
client = _make_client({
get_config_manager: lambda: mock_config,
get_task_manager: lambda: mock_task_manager,
})
resp = client.get("/api/dashboards/my-slug/tasks?env_id=env-1")
assert resp.status_code == 200
data = resp.json()
assert data["dashboard_id"] == 42
assert len(data["items"]) == 1
# ── get_dashboard_thumbnail — extra edge cases ──
class TestGetDashboardThumbnailExtended:
"""Additional thumbnail path coverage (lines 388-389, 400)."""
def test_thumbnail_202_json_error(self):
"""202 response where JSON parsing fails returns default message (lines 388-389)."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_async_client = AsyncMock()
async def _fake_request(method, endpoint, **kwargs):
if "cache_dashboard_screenshot" in endpoint:
return {"result": {"image_url": "/api/v1/dashboard/42/thumbnail/digest-1/"}}
if "raw_response" in kwargs and kwargs["raw_response"]:
mock_resp = MagicMock()
mock_resp.status_code = 202
mock_resp.headers = {"Content-Type": "application/json"}
mock_resp.json.side_effect = ValueError("Invalid JSON")
return mock_resp
return {}
mock_async_client.request.side_effect = _fake_request
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client", return_value=mock_async_client):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1")
assert resp.status_code == 202
assert resp.json() == {"message": "Thumbnail is being generated"}
def test_thumbnail_http_exception_re_raised(self):
"""HTTPException from get_superset_client is re-raised (line 400)."""
from fastapi import HTTPException as FastAPIHTTPException
mock_config = MagicMock()
mock_config.get_environments.return_value = [_make_mock_env(id="env-1")]
mock_client = AsyncMock()
mock_client.get_dashboards_page.return_value = (1, [{"id": 42}])
mock_gsc = AsyncMock()
async def _raise_gsc(*a, **kw):
raise FastAPIHTTPException(status_code=409, detail="Conflict")
mock_gsc.side_effect = _raise_gsc
from src.dependencies import get_config_manager
with patch("src.api.routes.dashboards._detail_routes.SupersetClient", return_value=mock_client), \
patch("src.api.routes.dashboards._detail_routes.get_superset_client", new=mock_gsc):
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/dashboards/42/thumbnail?env_id=env-1")
assert resp.status_code == 409
# #endregion Test.Api.DashboardDetailRoutes