test: 7 agents — plugins ~158 tests, extractor 98-100%, dashboard routes 99-100%, git services 94-100%, services 94-100%, dataset_review 100%. Fix test_api_key_routes.py sys.modules pollution

This commit is contained in:
2026-06-15 18:30:05 +03:00
parent 3de67c258a
commit 51d90f58c1
40 changed files with 9629 additions and 4 deletions

View File

@@ -222,6 +222,65 @@ class TestUpdateUser:
resp = client.put("/api/admin/users/user-999", json={"email": "x@y.com"})
assert resp.status_code == 404
def test_update_user_password_and_active(self):
"""Update with password and is_active covers lines 125, 127."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_repo = MagicMock()
existing_user = MagicMock()
existing_user.id = "user-1"
existing_user.username = "testuser"
existing_user.email = "old@example.com"
existing_user.is_active = False
existing_user.auth_source = "LOCAL"
existing_user.created_at = __import__("datetime").datetime.now()
existing_user.roles = []
mock_repo.get_user_by_id.return_value = existing_user
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.put("/api/admin/users/user-1", json={
"password": "NewStrongPass1",
"is_active": False,
})
assert resp.status_code == 200
assert existing_user.is_active is False
mock_session.commit.assert_called_once()
def test_update_user_with_roles(self):
"""Update with roles list covers lines 130-134."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_repo = MagicMock()
existing_user = MagicMock()
existing_user.id = "user-1"
existing_user.username = "testuser"
existing_user.email = "old@example.com"
existing_user.is_active = True
existing_user.auth_source = "LOCAL"
existing_user.created_at = __import__("datetime").datetime.now()
existing_user.roles = []
mock_repo.get_user_by_id.return_value = existing_user
def _get_role_by_name(name):
role = MagicMock()
role.id = f"role-{name}"
role.name = name
role.description = "A role"
role.permissions = []
return role
mock_repo.get_role_by_name.side_effect = _get_role_by_name
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.put("/api/admin/users/user-1", json={
"roles": ["Editor"],
})
assert resp.status_code == 200
assert len(existing_user.roles) == 1
mock_session.commit.assert_called_once()
class TestDeleteUser:
"""DELETE /api/admin/users/{user_id}"""
@@ -322,6 +381,36 @@ class TestCreateRole:
assert resp.status_code == 400
assert "Role already exists" in resp.text
def test_create_role_with_string_permission(self):
"""Create role with resource:action string permission (covers lines 220-221)."""
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_repo = MagicMock()
# get_permission_by_id returns None -> falls through to split
mock_repo.get_permission_by_id.return_value = None
mock_perm = MagicMock()
mock_perm.id = "perm-resolved"
mock_perm.resource = "users"
mock_perm.action = "read"
mock_repo.get_permission_by_resource_action.return_value = mock_perm
def _refresh_side_effect(obj):
obj.id = "new-role-id"
mock_session.refresh.side_effect = _refresh_side_effect
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
from src.core.database import get_auth_db
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.post("/api/admin/roles", json={
"name": "Editor",
"description": "Can edit",
"permissions": ["users:read"],
})
assert resp.status_code == 201
mock_repo.get_permission_by_resource_action.assert_called_once_with("users", "read")
mock_session.add.assert_called_once()
class TestUpdateRole:
"""PUT /api/admin/roles/{role_id}"""
@@ -357,6 +446,61 @@ class TestUpdateRole:
resp = client.put("/api/admin/roles/role-999", json={"name": "Ghost"})
assert resp.status_code == 404
def test_update_role_with_permissions(self):
"""Update role with permission changes (covers lines 261-269)."""
mock_session = MagicMock()
mock_repo = MagicMock()
existing_role = MagicMock()
existing_role.id = "role-1"
existing_role.name = "OldName"
existing_role.description = "Old desc"
existing_role.permissions = []
mock_repo.get_role_by_id.return_value = existing_role
mock_perm = MagicMock()
mock_perm.id = "perm-1"
mock_perm.resource = "test"
mock_perm.action = "read"
mock_repo.get_permission_by_id.return_value = mock_perm
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
from src.core.database import get_auth_db
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.put("/api/admin/roles/role-1", json={
"name": "Updated",
"permissions": ["perm-1"],
})
assert resp.status_code == 200
assert existing_role.name == "Updated"
assert len(existing_role.permissions) == 1
def test_update_role_with_string_permissions(self):
"""Update role with resource:action permission strings (covers lines 261-269 fallback)."""
mock_session = MagicMock()
mock_repo = MagicMock()
existing_role = MagicMock()
existing_role.id = "role-1"
existing_role.name = "OldName"
existing_role.description = "Old desc"
existing_role.permissions = []
mock_repo.get_role_by_id.return_value = existing_role
# get_permission_by_id returns None -> falls through
mock_repo.get_permission_by_id.return_value = None
mock_perm = MagicMock()
mock_perm.id = "perm-resolved"
mock_perm.resource = "users"
mock_perm.action = "write"
mock_repo.get_permission_by_resource_action.return_value = mock_perm
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
from src.core.database import get_auth_db
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.put("/api/admin/roles/role-1", json={
"name": "Updated",
"permissions": ["users:write"],
})
assert resp.status_code == 200
mock_repo.get_permission_by_resource_action.assert_called_once_with("users", "write")
class TestDeleteRole:
"""DELETE /api/admin/roles/{role_id}"""

View File

@@ -418,4 +418,121 @@ class TestGetDashboardThumbnail:
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

View File

@@ -464,4 +464,84 @@ class TestGetDashboards:
# 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

View File

@@ -276,4 +276,66 @@ class TestTaskMatchesDashboard:
task.plugin_id = "superset-backup"
task.params = {"dashboard_ids": [42], "environment_id": "env-1"}
assert _task_matches_dashboard(task, 42, "env-2") is False
def test_backup_no_env_id(self):
"""Backup task match with no env_id returns True (line 246)."""
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "superset-backup"
task.params = {"dashboard_ids": [42]}
assert _task_matches_dashboard(task, 42, None) is True
# ── _resolve_profile_actor_aliases — edge cases ──
class TestResolveProfileActorAliasesExtended:
"""Additional _resolve_profile_actor_aliases coverage (lines 171, 176-179)."""
@pytest.mark.asyncio
async def test_lookup_with_non_dict_items(self):
"""Non-dict items in lookup results are skipped (line 171)."""
from src.api.routes.dashboards._projection import _resolve_profile_actor_aliases
mock_adapter = AsyncMock()
mock_adapter.get_users_page.return_value = {
"items": [
"not a dict",
{"username": "alice", "display_name": "Alice Smith"},
]
}
mock_env = MagicMock()
mock_env.id = "env-1"
with patch("src.api.routes.dashboards._projection.AsyncSupersetClient") as MockClient, \
patch("src.api.routes.dashboards._projection.SupersetAccountLookupAdapter") as MockAdapter:
MockAdapter.return_value = mock_adapter
result = await _resolve_profile_actor_aliases(mock_env, "alice")
assert "alice" in result
assert "alice smith" in result
@pytest.mark.asyncio
async def test_lookup_no_exact_match_first_item_fallback(self):
"""When no exact username match, first dict item becomes matched_item (lines 176-179)."""
from src.api.routes.dashboards._projection import _resolve_profile_actor_aliases
mock_adapter = AsyncMock()
mock_adapter.get_users_page.return_value = {
"items": [
{"username": "bob", "display_name": "Bob Smith"},
]
}
mock_env = MagicMock()
mock_env.id = "env-1"
with patch("src.api.routes.dashboards._projection.AsyncSupersetClient") as MockClient, \
patch("src.api.routes.dashboards._projection.SupersetAccountLookupAdapter") as MockAdapter:
MockAdapter.return_value = mock_adapter
result = await _resolve_profile_actor_aliases(mock_env, "alice")
# alice not found, bob's display_name added as alias
assert "bob smith" in result
assert "alice" in result
# #endregion Test.Api.DashboardProjection

View File

@@ -352,4 +352,163 @@ class TestClearTasks:
client = _make_client({get_task_manager: lambda: mock_tm})
resp = client.delete("/api/tasks?status=SUCCESS")
assert resp.status_code == 204
def test_create_llm_documentation_task_with_provider(self):
"""Create task with llm_documentation — covers LLM provider block lines 80-123."""
from src.core.task_manager import Task
mock_tm = MagicMock()
mock_task = MagicMock(spec=Task)
mock_task.id = "task-llm"
mock_task.plugin_id = "llm_documentation"
mock_task.status = "PENDING"
mock_task.params = {"provider_id": "prov-1", "dashboard_id": 42}
mock_task.created_at = None
mock_task.user_id = "admin"
mock_task.input_request = None
mock_tm.create_task = AsyncMock(return_value=mock_task)
mock_db = MagicMock()
mock_llm_service = MagicMock()
mock_provider = MagicMock()
mock_provider.is_multimodal = True
mock_llm_service.get_provider.return_value = mock_provider
from src.dependencies import get_task_manager
with patch("src.core.database.SessionLocal", return_value=mock_db), \
patch("src.services.llm_provider.LLMProviderService", return_value=mock_llm_service):
client = _make_client({get_task_manager: lambda: mock_tm})
resp = client.post("/api/tasks", json={
"plugin_id": "llm_documentation",
"params": {"provider_id": "prov-1", "dashboard_id": 42},
})
assert resp.status_code == 201
mock_db.close.assert_called_once()
def test_create_llm_documentation_task_provider_not_found(self):
"""llm_documentation with invalid provider raises ValueError (line 112-113)."""
mock_tm = MagicMock()
mock_tm.create_task = AsyncMock()
mock_db = MagicMock()
mock_llm_service = MagicMock()
mock_llm_service.get_provider.return_value = None
from src.dependencies import get_task_manager
with patch("src.core.database.SessionLocal", return_value=mock_db), \
patch("src.services.llm_provider.LLMProviderService", return_value=mock_llm_service):
client = _make_client({get_task_manager: lambda: mock_tm})
resp = client.post("/api/tasks", json={
"plugin_id": "llm_documentation",
"params": {"provider_id": "bad-prov"},
})
# ValueError is raised, caught by the outer except -> 404
assert resp.status_code == 404
mock_db.close.assert_called_once()
def test_create_llm_documentation_no_provider_id_resolved(self):
"""llm_documentation without provider_id — resolved from config binding (lines 87-108)."""
from src.core.task_manager import Task
mock_tm = MagicMock()
mock_task = MagicMock(spec=Task)
mock_task.id = "task-llm"
mock_task.plugin_id = "llm_documentation"
mock_task.status = "PENDING"
mock_task.params = {"dashboard_id": 42}
mock_task.created_at = None
mock_task.user_id = "admin"
mock_task.input_request = None
mock_tm.create_task = AsyncMock(return_value=mock_task)
mock_db = MagicMock()
mock_llm_service = MagicMock()
mock_provider = MagicMock()
mock_provider.is_multimodal = True
mock_llm_service.get_provider.return_value = mock_provider
mock_llm_service.get_all_providers.return_value = []
mock_config = MagicMock()
mock_config.get_config.return_value.settings.llm = {}
from src.dependencies import get_task_manager, get_config_manager
with patch("src.core.database.SessionLocal", return_value=mock_db), \
patch("src.services.llm_provider.LLMProviderService", return_value=mock_llm_service), \
patch("src.api.routes.tasks.normalize_llm_settings", return_value={}), \
patch("src.api.routes.tasks.resolve_bound_provider_id", return_value=None):
client = _make_client({
get_task_manager: lambda: mock_tm,
get_config_manager: lambda: mock_config,
})
resp = client.post("/api/tasks", json={
"plugin_id": "llm_documentation",
"params": {"dashboard_id": 42},
})
assert resp.status_code == 201
mock_db.close.assert_called_once()
# ── Additional edge cases ──
def test_create_llm_documentation_provider_from_binding(self):
"""provider_id resolved from binding (line 100)."""
from src.core.task_manager import Task
mock_tm = MagicMock()
mock_task = MagicMock(spec=Task)
mock_task.id = "task-llm"
mock_task.plugin_id = "llm_documentation"
mock_task.status = "PENDING"
mock_task.params = {"dashboard_id": 42}
mock_task.created_at = None
mock_task.user_id = "admin"
mock_task.input_request = None
mock_tm.create_task = AsyncMock(return_value=mock_task)
mock_db = MagicMock()
mock_llm_service = MagicMock()
mock_provider = MagicMock()
mock_provider.is_multimodal = True
mock_llm_service.get_provider.return_value = mock_provider
mock_llm_service.get_all_providers.return_value = []
from src.dependencies import get_task_manager
with patch("src.core.database.SessionLocal", return_value=mock_db), \
patch("src.services.llm_provider.LLMProviderService", return_value=mock_llm_service), \
patch("src.api.routes.tasks.resolve_bound_provider_id", return_value="prov-binding"):
client = _make_client({get_task_manager: lambda: mock_tm})
resp = client.post("/api/tasks", json={
"plugin_id": "llm_documentation",
"params": {"dashboard_id": 42},
})
assert resp.status_code == 201
def test_create_llm_documentation_provider_from_active(self):
"""provider_id resolved from active provider fallback (lines 107-108)."""
from src.core.task_manager import Task
mock_tm = MagicMock()
mock_task = MagicMock(spec=Task)
mock_task.id = "task-llm"
mock_task.plugin_id = "llm_documentation"
mock_task.status = "PENDING"
mock_task.params = {"dashboard_id": 42}
mock_task.created_at = None
mock_task.user_id = "admin"
mock_task.input_request = None
mock_tm.create_task = AsyncMock(return_value=mock_task)
mock_db = MagicMock()
mock_llm_service = MagicMock()
mock_provider = MagicMock()
mock_provider.id = "prov-active"
mock_provider.is_active = True
mock_provider.is_multimodal = True
mock_llm_service.get_provider.return_value = mock_provider
mock_llm_service.get_all_providers.return_value = [mock_provider]
from src.dependencies import get_task_manager
with patch("src.core.database.SessionLocal", return_value=mock_db), \
patch("src.services.llm_provider.LLMProviderService", return_value=mock_llm_service), \
patch("src.api.routes.tasks.resolve_bound_provider_id", return_value=None):
client = _make_client({get_task_manager: lambda: mock_tm})
resp = client.post("/api/tasks", json={
"plugin_id": "llm_documentation",
"params": {"dashboard_id": 42},
})
assert resp.status_code == 201
# #endregion Test.Api.Tasks