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

548 lines
25 KiB
Python

# #region Test.Api.DashboardListingRoutes [C:3] [TYPE Module] [SEMANTICS test,dashboard,listing,api]
# @BRIEF Unit tests for dashboard listing route — get_dashboards.
# @RELATION BINDS_TO -> [DashboardListingRoutes]
# @TEST_EDGE: page_less_than_one -> 400
# @TEST_EDGE: page_size_invalid -> 400
# @TEST_EDGE: environment_not_found -> 404
# @TEST_EDGE: superset_fail -> 503
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, HTTPException
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"
return env
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.dashboards._listing_routes import router
from src.core.database import get_db
from src.dependencies import (
get_config_manager,
get_current_user,
get_resource_service,
get_task_manager,
has_permission,
)
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router)
mock_user = User(
id="user-1",
username="testuser",
email="test@example.com",
auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(),
roles=[RoleSchema(id="role-1", name="Admin", description="Admin", permissions=[])],
)
# Map string keys (from fixtures) to dependency function references
_DEP_KEY_MAP = {
"db": get_db,
"config_manager": get_config_manager,
"task_manager": get_task_manager,
"resource_service": get_resource_service,
}
defaults = {
get_db: lambda: MagicMock(),
get_config_manager: lambda: MagicMock(),
get_task_manager: lambda: MagicMock(),
get_resource_service: lambda: AsyncMock(),
get_current_user: lambda: mock_user,
has_permission: lambda *a, **kw: lambda: None,
}
if overrides:
for key, value in overrides.items():
resolved_key = _DEP_KEY_MAP.get(key, key)
# Wrap MagicMock/AsyncMock values so FastAPI's dependency resolution
# calls the lambda and gets the configured mock, not a new instance
defaults[resolved_key] = (lambda v=value: v) if isinstance(value, (MagicMock, AsyncMock)) else value
for dep, mock_fn in defaults.items():
app.dependency_overrides[dep] = mock_fn
return TestClient(app)
class TestGetDashboards:
"""GET /api/dashboards"""
@pytest.fixture
def base_mocks(self):
env = _make_mock_env("env-1")
mock_config = MagicMock()
mock_config.get_environments.return_value = [env]
mock_task_manager = MagicMock()
mock_task_manager.get_all_tasks.return_value = []
mock_rs = AsyncMock()
mock_rs.get_dashboards_page_with_status.return_value = {
"dashboards": [
{"id": 1, "title": "Main Dashboard", "slug": "main", "owners": []},
],
"total": 1,
"total_pages": 1,
}
mock_db = MagicMock()
return {
"config_manager": mock_config,
"task_manager": mock_task_manager,
"resource_service": mock_rs,
"db": mock_db,
}
def test_get_dashboards_success(self, base_mocks):
"""Happy path: returns paginated dashboards."""
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert len(data["dashboards"]) == 1
assert data["dashboards"][0]["title"] == "Main Dashboard"
def test_get_dashboards_page_less_than_one(self, base_mocks):
"""Page < 1 returns 400."""
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&page=0")
assert resp.status_code == 400
assert "Page must be >= 1" in resp.text
def test_get_dashboards_page_size_invalid(self, base_mocks):
"""page_size < 1 returns 400."""
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_size=0")
assert resp.status_code == 400
def test_get_dashboards_page_size_too_large(self, base_mocks):
"""page_size > 100 returns 400."""
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_size=101")
assert resp.status_code == 400
assert "Page size must be between 1 and 100" in resp.text
def test_get_dashboards_env_not_found(self, base_mocks):
"""Non-existent env returns 404."""
mock_config = MagicMock()
mock_config.get_environments.return_value = []
mocks = {**base_mocks, "config_manager": mock_config}
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-ghost")
assert resp.status_code == 404
assert "Environment not found" in resp.text
def test_get_dashboards_with_search(self, base_mocks):
"""Search filter applied via fallback path."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Revenue Dashboard", "slug": "revenue", "owners": []},
{"id": 2, "title": "Sales Report", "slug": "sales", "owners": []},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&search=revenue")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["dashboards"][0]["title"] == "Revenue Dashboard"
def test_get_dashboards_full_scan_with_filters(self, base_mocks):
"""Column filters trigger full scan path."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main Dashboard", "slug": "main", "owners": [],
"git_status": {"sync_status": "OK", "has_repo": True}},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_git_status=ok")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
def test_get_dashboards_internal_exception(self, base_mocks):
"""Internal error returns 503."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("Critical failure")
# Also make the fallback fail to trigger the outer 503 handler
mock_rs.get_dashboards_with_status.side_effect = Exception("Fallback also fails")
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1")
assert resp.status_code == 503
assert "Failed to fetch dashboards" in resp.text
def test_get_dashboards_slug_filter_only(self, base_mocks):
"""Slug-only filter."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "", "owners": []},
{"id": 2, "title": "With Slug", "slug": "with-slug", "owners": []},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=false&override_show_all=true")
assert resp.status_code == 200
def test_get_dashboards_title_filters(self, base_mocks):
"""Title column filter applied."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Revenue Dashboard", "slug": "revenue", "owners": []},
{"id": 2, "title": "Sales Dashboard", "slug": "sales", "owners": []},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_title=revenue+dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
def test_get_dashboards_actor_filter(self, base_mocks):
"""Actor column filter applied."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "main", "owners": [], "last_modified": "2024-01-01"},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_actor=alice")
assert resp.status_code == 200
def test_get_dashboards_changed_on_filter(self, base_mocks):
"""Changed-on filter applied."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "main", "owners": [], "last_modified": "2024-01-01T12:00:00"},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_changed_on=2024-01-01")
assert resp.status_code == 200
def test_get_dashboards_page_fallback_to_full_scan(self, base_mocks):
"""When page-based fetch fails, fallback to full scan."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "First", "slug": "first", "owners": []},
{"id": 2, "title": "Second", "slug": "second", "owners": []},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&page=1&page_size=1")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert data["total_pages"] == 2
assert len(data["dashboards"]) == 1
def test_get_dashboards_llm_filter(self, base_mocks):
"""LLM status column filter applied."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "main", "owners": [],
"last_task": {"validation_status": "PASS"}, "git_status": {"sync_status": "OK", "has_repo": True}},
{"id": 2, "title": "Other", "slug": "other", "owners": [],
"last_task": {"validation_status": "FAIL"}, "git_status": {"sync_status": "OK", "has_repo": True}},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_llm_status=pass")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
def test_get_dashboards_503_on_full_scan_fail(self, base_mocks):
"""When both page and full scan fail, returns 503."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("Page fail")
mock_rs.get_dashboards_with_status.side_effect = Exception("Full scan fail")
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_git_status=ok")
assert resp.status_code == 503
assert "Failed to fetch dashboards" in resp.text
def test_get_dashboards_override_show_all(self, base_mocks):
"""override_show_all disables profile filter."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "main", "owners": ["admin"]},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&override_show_all=true")
assert resp.status_code == 200
def test_get_dashboards_git_filter_no_match(self, base_mocks):
"""Git filter excludes non-matching dashboards."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "main", "owners": [],
"git_status": {"sync_status": "DIFF", "has_repo": True}},
{"id": 2, "title": "Other", "slug": "other", "owners": [],
"git_status": {"sync_status": "OK", "has_repo": True}},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_git_status=ok")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["dashboards"][0]["id"] == 2
def test_get_dashboards_actor_filter_non_list_owner(self, base_mocks):
"""Actor filter with non-list owner value (string) doesn't crash."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "main", "owners": "admin"},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_actor=admin")
assert resp.status_code == 200
def test_get_dashboards_full_scan_with_search(self, base_mocks):
"""Search in full scan path (triggered by column filter)."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Revenue Dashboard", "slug": "revenue", "owners": [], "git_status": {"sync_status": "OK", "has_repo": True}},
{"id": 2, "title": "Sales Report", "slug": "sales", "owners": [], "git_status": {"sync_status": "DIFF", "has_repo": True}},
]
# Use filter_git_status to force full_scan path + search narrows results
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&search=revenue&filter_git_status=ok")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
def test_get_dashboards_slug_filter_full_scan(self, base_mocks):
"""Slug-only filter applied in full scan path."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "No Slug", "slug": "", "owners": []},
{"id": 2, "title": "Has Slug", "slug": "has-slug", "owners": []},
]
mocks = dict(base_mocks)
profile_svc = MagicMock()
profile_svc.get_dashboard_filter_binding.return_value = {
"superset_username": "",
"superset_username_normalized": "",
"show_only_my_dashboards": False,
"show_only_slug_dashboards": True,
}
profile_svc.matches_dashboard_actor.return_value = False
with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
MockPS.__module__ = "unittest.mock"
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=true")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["dashboards"][0]["slug"] == "has-slug"
def test_get_dashboards_profile_filter_with_actor(self, base_mocks):
"""Profile filter with actor alias matching."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "testuser"}], "modified_by": None},
{"id": 2, "title": "Other Dash", "slug": "other", "owners": [{"username": "other"}], "modified_by": None},
]
mocks = dict(base_mocks)
profile_svc = MagicMock()
profile_svc.get_dashboard_filter_binding.return_value = {
"superset_username": "testuser",
"superset_username_normalized": "testuser",
"show_only_my_dashboards": True,
"show_only_slug_dashboards": False,
}
profile_svc.matches_dashboard_actor.side_effect = lambda bound_username, owners, modified_by: (
any("testuser" in str(o) for o in (owners or []))
)
with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
MockPS.__module__ = "unittest.mock"
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=true")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
def test_get_dashboards_profile_error_fallback(self, base_mocks):
"""Profile service error falls back gracefully."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.return_value = {
"dashboards": [{"id": 1, "title": "Main", "slug": "main", "owners": []}],
"total": 1,
"total_pages": 1,
}
mocks = dict(base_mocks)
with patch("src.api.routes.dashboards._listing_routes.ProfileService") as MockPS:
MockPS.side_effect = Exception("Profile error")
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-1")
assert resp.status_code == 200
assert resp.json()["total"] == 1
def test_get_dashboards_actor_filter_no_match(self, base_mocks):
"""Actor filter with no matching owners."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "main", "owners": ["alice", "bob"]},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_actor=nonexistent")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
def test_get_dashboards_full_scan_profile_and_slug(self, base_mocks):
"""Both profile filter and slug filter active."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "testuser"}], "modified_by": None},
{"id": 2, "title": "No Slug", "slug": "", "owners": [{"username": "testuser"}], "modified_by": None},
]
mocks = dict(base_mocks)
profile_svc = MagicMock()
profile_svc.get_dashboard_filter_binding.return_value = {
"superset_username": "testuser",
"superset_username_normalized": "testuser",
"show_only_my_dashboards": True,
"show_only_slug_dashboards": True,
}
profile_svc.matches_dashboard_actor.return_value = True
with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
MockPS.__module__ = "unittest.mock"
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=true")
assert resp.status_code == 200
data = resp.json()
# Only dash-1 has a slug
assert data["total"] == 1
assert data["dashboards"][0]["slug"] == "my-dash"
def test_get_dashboards_profile_filter_empty_aliases(self, base_mocks):
"""Profile filter with empty actor aliases falls back to bound_username (line 252)."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "My Dash", "slug": "my-dash", "owners": [{"username": "testuser"}], "modified_by": None},
{"id": 2, "title": "Other Dash", "slug": "other", "owners": [{"username": "other"}], "modified_by": None},
]
mocks = dict(base_mocks)
profile_svc = MagicMock()
profile_svc.get_dashboard_filter_binding.return_value = {
"superset_username": "testuser",
"superset_username_normalized": "testuser",
"show_only_my_dashboards": True,
"show_only_slug_dashboards": False,
}
profile_svc.matches_dashboard_actor.side_effect = lambda bound_username, owners, modified_by: (
any("testuser" in str(o) for o in (owners or []))
)
mocks["_profile_service"] = profile_svc
with patch("src.api.routes.dashboards._listing_routes.ProfileService", return_value=profile_svc) as MockPS:
MockPS.__module__ = "unittest.mock"
async def _empty_aliases(*args, **kwargs):
return []
with patch("src.api.routes.dashboards._listing_routes._resolve_profile_actor_aliases", side_effect=_empty_aliases):
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-1&page_context=dashboards_main&apply_profile_default=true")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
def test_get_dashboards_changed_on_no_match(self, base_mocks):
"""Changed-on filter value does not match dashboard (line 337 return False)."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Main", "slug": "main", "owners": [], "last_modified": "2024-01-01T12:00:00"},
]
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_changed_on=2099-12-31")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
def test_get_dashboards_http_exception_re_raised(self, base_mocks):
"""HTTPException from full-scan path is re-raised (line 382 except HTTPException)."""
from fastapi import HTTPException as FastAPIHTTPException
mock_rs = base_mocks["resource_service"]
async def _raise_conflict(*a, **kw):
raise FastAPIHTTPException(status_code=409, detail="Conflict from service")
mock_rs.get_dashboards_with_status.side_effect = _raise_conflict
client = _make_client(base_mocks)
resp = client.get("/api/dashboards?env_id=env-1&filter_git_status=ok")
assert resp.status_code == 409
def test_get_dashboards_slug_fallback_no_git(self, base_mocks):
"""Slug-only filter via fallback when page fetch fails and needs_full_scan is false."""
mock_rs = base_mocks["resource_service"]
mock_rs.get_dashboards_page_with_status.side_effect = Exception("No page support")
mock_rs.get_dashboards_with_status.return_value = [
{"id": 1, "title": "Has Slug", "slug": "has-slug", "owners": []},
{"id": 2, "title": "No Slug", "slug": "", "owners": []},
]
mocks = dict(base_mocks)
with patch("src.api.routes.dashboards._listing_routes.ProfileService") as MockPS:
profile_svc = MagicMock()
profile_svc.get_dashboard_filter_binding.return_value = {
"superset_username": "",
"superset_username_normalized": "",
"show_only_my_dashboards": False,
"show_only_slug_dashboards": False,
}
MockPS.return_value = profile_svc
MockPS.__module__ = "unittest.mock"
client = _make_client(mocks)
resp = client.get("/api/dashboards?env_id=env-1")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
# #endregion Test.Api.DashboardListingRoutes