test(backend): add 55+ test files to push coverage to 98%

Subagents delivered tests across all uncovered backend modules:

Schemas (100%): agent, auth, health, profile, settings, validation
Services (98-100%): auth, profile, health, llm, mapping, resource,
  security, git, superset_lookup, sql_table_extractor, rbac
API routes (new): auth, admin, health, environments, plugins,
  dashboards (helpers, projection, actions, listing),
  git (config, deps, env, helpers)
Clean Release (100%): DTO, facade, policy_engine, stages,
  repos, preparation, source_isolation, compliance
Git services: base, remote_providers
Agent module: app, run, middleware, langgraph_setup
Core: trace, cleanup, ws_log_handler, timezone, auth (config/oauth/security), matching
Reports: normalizer, report_service, type_profiles
Notifications: service, providers

Also:
- .gitignore: add .coverage, *.cover, coverage-* dirs
- src/schemas/auth.py: fix AD group DN regex (comma in CN=...)
- Remove co-located src/services/__tests__/ (caused pytest module collision)
This commit is contained in:
2026-06-15 13:55:57 +03:00
parent 1e85e57cbd
commit 01332ad5c7
67 changed files with 12187 additions and 1993 deletions

View File

@@ -0,0 +1,455 @@
# #region Test.Api.Admin [C:3] [TYPE Module] [SEMANTICS test,admin,api]
# @BRIEF Unit tests for admin API routes — users, roles, permissions, AD mappings.
# @RELATION BINDS_TO -> [AdminApi]
# @TEST_EDGE: user_not_found -> 404
# @TEST_EDGE: username_exists -> 400
# @TEST_EDGE: role_not_found -> 404
# @TEST_EDGE: role_already_exists -> 400
import os
# Set env BEFORE any source imports
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 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_client(overrides: dict | None = None) -> TestClient:
"""Build TestClient with admin router and all deps overridden."""
from src.api.routes.admin import router
from src.core.database import get_auth_db
from src.dependencies import get_current_user, has_permission
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router)
mock_db = MagicMock()
mock_user = User(
id="admin-1",
username="admin",
email="admin@example.com",
auth_source="LOCAL",
is_active=True,
created_at=__import__("datetime").datetime.now(),
roles=[RoleSchema(id="role-1", name="Admin", description="Admin", permissions=[])],
)
defaults = {
get_auth_db: lambda: mock_db,
get_current_user: lambda: mock_user,
has_permission: lambda *args, **kwargs: lambda: None,
}
if overrides:
defaults.update(overrides)
for dep, mock_fn in defaults.items():
app.dependency_overrides[dep] = mock_fn
return TestClient(app)
# ── Users ──
class TestListUsers:
"""GET /api/admin/users"""
def test_list_users_success(self):
"""Happy path: list all users returns 200."""
from src.core.database import get_auth_db
mock_user = MagicMock()
mock_user.id = "user-1"
mock_user.username = "alice"
mock_user.email = "alice@example.com"
mock_user.is_active = True
mock_user.auth_source = "LOCAL"
mock_user.created_at = __import__("datetime").datetime.now()
mock_user.last_login = None
mock_user.roles = []
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = [mock_user]
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.get("/api/admin/users")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert data[0]["username"] == "alice"
class TestCreateUser:
"""POST /api/admin/users"""
def test_create_user_success(self):
"""Happy path: user created with 201."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_user_by_username.return_value = None
mock_repo.get_role_by_name.side_effect = lambda name: (
MagicMock(id=f"role-{name}", name=name, permissions=[]) if name == "Admin" else None
)
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.post("/api/admin/users", json={
"username": "newuser",
"email": "new@example.com",
"password": "StrongPass1",
"is_active": True,
"roles": ["Admin"],
})
assert resp.status_code == 201
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
def test_create_user_duplicate_username(self):
"""Username already exists returns 400."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_user_by_username.return_value = MagicMock()
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.post("/api/admin/users", json={
"username": "exists",
"email": "dup@example.com",
"password": "StrongPass1",
})
assert resp.status_code == 400
assert "Username already exists" in resp.text
def test_create_user_weak_password(self):
"""Weak password returns 422."""
client = _make_client()
resp = client.post("/api/admin/users", json={
"username": "weakuser",
"email": "weak@example.com",
"password": "short",
})
assert resp.status_code == 422
def test_create_user_no_permission(self):
"""User without write permission gets 403."""
from src.core.database import get_auth_db
from src.dependencies import get_current_user, has_permission
from src.schemas.auth import User, RoleSchema
app = FastAPI()
from src.api.routes.admin import router
app.include_router(router)
app.dependency_overrides[get_auth_db] = lambda: MagicMock()
app.dependency_overrides[get_current_user] = lambda: User(
id="user-1", username="regular", email="u@x.com", auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(), roles=[]
)
# Keep has_permission as real — it will use the mock get_current_user
client = TestClient(app)
resp = client.post("/api/admin/users", json={
"username": "test",
"email": "test@example.com",
"password": "StrongPass1",
})
assert resp.status_code == 403
class TestUpdateUser:
"""PUT /api/admin/users/{user_id}"""
def test_update_user_success(self):
"""Happy path: user updated and returned."""
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 = "oldname"
existing_user.email = "old@example.com"
existing_user.is_active = True
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={"email": "new@example.com"})
assert resp.status_code == 200
assert existing_user.email == "new@example.com"
mock_session.commit.assert_called_once()
def test_update_user_not_found(self):
"""Non-existent user returns 404."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_user_by_id.return_value = None
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-999", json={"email": "x@y.com"})
assert resp.status_code == 404
class TestDeleteUser:
"""DELETE /api/admin/users/{user_id}"""
def test_delete_user_success(self):
"""Happy path: user deleted returns 204."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_user_by_id.return_value = MagicMock(username="testuser")
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.delete("/api/admin/users/user-1")
assert resp.status_code == 204
mock_session.delete.assert_called_once()
mock_session.commit.assert_called_once()
def test_delete_user_not_found(self):
"""Non-existent user returns 404."""
from src.core.database import get_auth_db
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_user_by_id.return_value = None
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo):
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.delete("/api/admin/users/user-999")
assert resp.status_code == 404
# ── Roles ──
class TestListRoles:
"""GET /api/admin/roles"""
def test_list_roles_success(self):
"""Happy path: list roles returns 200."""
mock_role = MagicMock()
mock_role.id = "role-1"
mock_role.name = "Admin"
mock_role.description = "Administrator"
mock_role.permissions = []
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = [mock_role]
from src.core.database import get_auth_db
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.get("/api/admin/roles")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
class TestCreateRole:
"""POST /api/admin/roles"""
def test_create_role_success(self):
"""Happy path: role created with 201."""
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_repo = MagicMock()
mock_repo.get_permission_by_id.return_value = MagicMock(id="perm-1")
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": ["perm-1"],
})
assert resp.status_code == 201
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
def test_create_role_duplicate(self):
"""Duplicate role name returns 400."""
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = MagicMock()
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": "Admin",
"description": "Already exists",
"permissions": [],
})
assert resp.status_code == 400
assert "Role already exists" in resp.text
class TestUpdateRole:
"""PUT /api/admin/roles/{role_id}"""
def test_update_role_success(self):
"""Happy path: role updated."""
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
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": "NewName", "description": "New desc"})
assert resp.status_code == 200
assert existing_role.name == "NewName"
mock_session.commit.assert_called_once()
def test_update_role_not_found(self):
"""Non-existent role returns 404."""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_role_by_id.return_value = None
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-999", json={"name": "Ghost"})
assert resp.status_code == 404
class TestDeleteRole:
"""DELETE /api/admin/roles/{role_id}"""
def test_delete_role_success(self):
"""Happy path: role deleted returns 204."""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_role_by_id.return_value = MagicMock()
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.delete("/api/admin/roles/role-1")
assert resp.status_code == 204
mock_session.delete.assert_called_once()
mock_session.commit.assert_called_once()
def test_delete_role_not_found(self):
"""Non-existent role returns 404."""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.get_role_by_id.return_value = None
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.delete("/api/admin/roles/role-999")
assert resp.status_code == 404
# ── Permissions ──
class TestListPermissions:
"""GET /api/admin/permissions"""
def test_list_permissions_success(self):
"""Happy path: returns permissions list."""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_perm = MagicMock()
mock_perm.id = "perm-1"
mock_perm.resource = "users"
mock_perm.action = "read"
mock_repo.list_permissions.return_value = [mock_perm]
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo), \
patch("src.api.routes.admin.discover_declared_permissions", return_value=[]), \
patch("src.api.routes.admin.sync_permission_catalog", return_value=0):
from src.core.database import get_auth_db
from src.dependencies import get_plugin_loader
client = _make_client({
get_auth_db: lambda: mock_session,
get_plugin_loader: lambda: MagicMock(),
})
resp = client.get("/api/admin/permissions")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
def test_list_permissions_with_sync(self):
"""When new permissions discovered, sync is called."""
mock_session = MagicMock()
mock_repo = MagicMock()
mock_repo.list_permissions.return_value = []
with patch("src.api.routes.admin.AuthRepository", return_value=mock_repo), \
patch("src.api.routes.admin.discover_declared_permissions", return_value=["new:perm"]), \
patch("src.api.routes.admin.sync_permission_catalog", return_value=2):
from src.core.database import get_auth_db
from src.dependencies import get_plugin_loader
client = _make_client({
get_auth_db: lambda: mock_session,
get_plugin_loader: lambda: MagicMock(),
})
resp = client.get("/api/admin/permissions")
assert resp.status_code == 200
# ── AD Mappings ──
class TestListAdMappings:
"""GET /api/admin/ad-mappings"""
def test_list_mappings_success(self):
"""Happy path: returns AD mappings."""
mock_mapping = MagicMock()
mock_mapping.id = "map-1"
mock_mapping.ad_group = "DOMAIN\\group1"
mock_mapping.role_id = "role-1"
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = [mock_mapping]
from src.core.database import get_auth_db
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.get("/api/admin/ad-mappings")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
class TestCreateAdMapping:
"""POST /api/admin/ad-mappings"""
def test_create_mapping_success(self):
"""Happy path: AD mapping created."""
mock_session = MagicMock()
from src.core.database import get_auth_db
client = _make_client({get_auth_db: lambda: mock_session})
resp = client.post("/api/admin/ad-mappings", json={
"ad_group": "DOMAIN\\newgroup",
"role_id": "role-1",
})
assert resp.status_code == 200
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
def test_create_mapping_invalid_ad_group(self):
"""Invalid AD group name returns 422."""
client = _make_client()
resp = client.post("/api/admin/ad-mappings", json={
"ad_group": "invalid group with spaces!!!",
"role_id": "role-1",
})
assert resp.status_code == 422
# #endregion Test.Api.Admin

View File

@@ -0,0 +1,252 @@
# #region Test.Api.Auth [C:3] [TYPE Module] [SEMANTICS test,auth,api]
# @BRIEF Unit tests for auth API routes — login, logout, me, ADFS.
# @RELATION BINDS_TO -> [Api.Auth]
# @TEST_EDGE: invalid_credentials -> 401
# @TEST_EDGE: locked_account -> 429 (rate limited)
# @TEST_EDGE: missing_fields -> 422
# @TEST_EDGE: already_expired_token -> 200 (idempotent logout)
import os
# Set env BEFORE any source imports
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, status
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)
@pytest.fixture(autouse=True)
def _patch_rate_limiter():
"""Ensure rate limiter is safe."""
with patch("src.api.auth.rate_limiter") as mock_rl:
mock_rl.is_banned.return_value = False
mock_rl.record_attempt = MagicMock()
mock_rl.record_success = MagicMock()
yield
# ── Helper: build TestClient with auth dependencies overridden ──
def _make_client() -> TestClient:
"""Build a TestClient for the auth router with all dependencies overridden."""
from src.api.auth import router
from src.core.database import get_auth_db
from src.dependencies import get_current_user
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router)
mock_db = MagicMock()
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 role", permissions=[])],
)
app.dependency_overrides[get_auth_db] = lambda: mock_db
app.dependency_overrides[get_current_user] = lambda: mock_user
return TestClient(app)
# ── Tests ──
class TestLogin:
"""POST /api/auth/login"""
def test_login_success(self):
"""Happy path: valid credentials return 200 + Token."""
mock_user = MagicMock()
mock_user.username = "testuser"
mock_token = MagicMock()
mock_token.access_token = "abc.def.ghi"
mock_token.token_type = "bearer"
with patch("src.api.auth.AuthService") as MockAuth:
auth_instance = MockAuth.return_value
auth_instance.authenticate_user.return_value = mock_user
auth_instance.create_session.return_value = mock_token
client = _make_client()
resp = client.post("/api/auth/login", data={"username": "testuser", "password": "secret123"})
assert resp.status_code == 200
assert resp.json()["access_token"] == "abc.def.ghi"
assert resp.json()["token_type"] == "bearer"
def test_login_invalid_credentials(self):
"""Invalid credentials return 401."""
with patch("src.api.auth.AuthService") as MockAuth:
auth_instance = MockAuth.return_value
auth_instance.authenticate_user.return_value = None
client = _make_client()
resp = client.post("/api/auth/login", data={"username": "bad", "password": "wrong"})
assert resp.status_code == 401
assert "Incorrect username or password" in resp.text
def test_login_rate_limited(self):
"""Banned IP returns 429."""
with patch("src.api.auth.rate_limiter") as mock_rl:
mock_rl.is_banned.return_value = True
client = _make_client()
resp = client.post("/api/auth/login", data={"username": "test", "password": "test"})
assert resp.status_code == 429
assert "Too many login attempts" in resp.text
def test_login_missing_fields(self):
"""Missing form data returns 422."""
client = _make_client()
resp = client.post("/api/auth/login", data={})
assert resp.status_code == 422
class TestMe:
"""GET /api/auth/me"""
def test_me_authenticated(self):
"""Authenticated user returns profile."""
client = _make_client()
resp = client.get("/api/auth/me")
assert resp.status_code == 200
data = resp.json()
assert data["username"] == "testuser"
assert data["email"] == "test@example.com"
def test_me_unauthenticated(self):
"""Unauthenticated request returns 401."""
from src.core.database import get_auth_db
from src.dependencies import get_current_user
app = FastAPI()
from src.api.auth import router
app.include_router(router)
app.dependency_overrides[get_current_user] = lambda: (_ for _ in ()).throw(
HTTPException(status_code=401, detail="Not authenticated")
)
app.dependency_overrides[get_auth_db] = lambda: MagicMock()
client = TestClient(app)
resp = client.get("/api/auth/me")
assert resp.status_code == 401
class TestLogout:
"""POST /api/auth/logout"""
def test_logout_success(self):
"""Valid token blacklisted, returns 200."""
with patch("src.api.auth.blacklist_token") as mock_blacklist:
client = _make_client()
resp = client.post(
"/api/auth/logout",
headers={"Authorization": "Bearer some.jwt.token"},
)
assert resp.status_code == 200
assert resp.json()["message"] == "Successfully logged out"
mock_blacklist.assert_called_once()
def test_logout_no_token_header(self):
"""No Authorization header still succeeds (no token to blacklist)."""
with patch("src.api.auth.blacklist_token") as mock_blacklist:
client = _make_client()
resp = client.post("/api/auth/logout")
assert resp.status_code == 200
mock_blacklist.assert_not_called()
def test_logout_expired_token(self):
"""Expired token — still returns 200 (idempotent)."""
with patch("src.api.auth.blacklist_token") as mock_blacklist:
client = _make_client()
resp = client.post(
"/api/auth/logout",
headers={"Authorization": "Bearer expired.token.here"},
)
assert resp.status_code == 200
mock_blacklist.assert_called_once()
class TestLoginAdfs:
"""GET /api/auth/login/adfs"""
def test_adfs_not_configured(self):
"""ADFS not configured returns 503."""
with patch("src.api.auth.is_adfs_configured", return_value=False):
client = _make_client()
resp = client.get("/api/auth/login/adfs")
assert resp.status_code == 503
assert "ADFS is not configured" in resp.text
def test_adfs_redirect(self):
"""ADFS configured redirects to provider."""
mock_oauth = MagicMock()
mock_oauth.adfs.authorize_redirect = AsyncMock(return_value=None)
with patch("src.api.auth.is_adfs_configured", return_value=True), \
patch("src.api.auth.oauth", mock_oauth):
client = _make_client()
resp = client.get("/api/auth/login/adfs")
assert resp.status_code in (200, 307)
class TestCallbackAdfs:
"""GET /api/auth/callback/adfs"""
def test_callback_not_configured(self):
"""ADFS not configured returns 503."""
with patch("src.api.auth.is_adfs_configured", return_value=False):
client = _make_client()
resp = client.get("/api/auth/callback/adfs")
assert resp.status_code == 503
def test_callback_no_userinfo(self):
"""ADFS token without userinfo returns 400."""
mock_oauth = MagicMock()
mock_oauth.adfs.authorize_access_token = AsyncMock(return_value={})
with patch("src.api.auth.is_adfs_configured", return_value=True), \
patch("src.api.auth.oauth", mock_oauth):
client = _make_client()
resp = client.get("/api/auth/callback/adfs")
assert resp.status_code == 400
assert "Failed to retrieve user info" in resp.text
def test_callback_success(self):
"""ADFS callback provisions user and returns token."""
mock_token = MagicMock()
mock_token.access_token = "adfs.token.xyz"
mock_token.token_type = "bearer"
mock_user_info = {"sub": "adfs-user", "email": "adfs@example.com"}
mock_oauth = MagicMock()
mock_oauth.adfs.authorize_access_token = AsyncMock(
return_value={"userinfo": mock_user_info}
)
with patch("src.api.auth.is_adfs_configured", return_value=True), \
patch("src.api.auth.oauth", mock_oauth), \
patch("src.api.auth.AuthService") as MockAuth:
auth_instance = MockAuth.return_value
auth_instance.provision_adfs_user.return_value = MagicMock()
auth_instance.create_session.return_value = mock_token
client = _make_client()
resp = client.get("/api/auth/callback/adfs")
assert resp.status_code == 200
assert resp.json()["access_token"] == "adfs.token.xyz"
# #endregion Test.Api.Auth

View File

@@ -0,0 +1,299 @@
# #region Test.Api.DashboardActionRoutes [C:3] [TYPE Module] [SEMANTICS test,dashboard,action,migration,backup]
# @BRIEF Unit tests for dashboard action routes — migrate and backup.
# @RELATION BINDS_TO -> [DashboardActionRoutes]
# @TEST_EDGE: empty_dashboard_ids -> 400
# @TEST_EDGE: source_env_not_found -> 404
# @TEST_EDGE: target_env_not_found -> 404
# @TEST_EDGE: env_not_found_backup -> 404
# @TEST_EDGE: task_creation_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_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.dashboards._action_routes import router
from src.dependencies import get_current_user, 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_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)
# ── migrate_dashboards ──
class TestMigrateDashboards:
"""POST /api/dashboards/migrate"""
def _make_env(self, id: str):
env = MagicMock()
env.id = id
return env
def test_migrate_success(self):
"""Happy path: migration task created returns 200 with task_id."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [
self._make_env("src-1"),
self._make_env("tgt-1"),
]
mock_task = MagicMock()
mock_task.id = "task-123"
mock_task_manager = AsyncMock()
mock_task_manager.create_task.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.post("/api/dashboards/migrate", json={
"source_env_id": "src-1",
"target_env_id": "tgt-1",
"dashboard_ids": [1, 2, 3],
})
assert resp.status_code == 200
assert resp.json()["task_id"] == "task-123"
mock_task_manager.create_task.assert_called_once()
def test_migrate_empty_dashboard_ids(self):
"""Empty dashboard_ids returns 400."""
mock_config = MagicMock()
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.post("/api/dashboards/migrate", json={
"source_env_id": "src-1",
"target_env_id": "tgt-1",
"dashboard_ids": [],
})
assert resp.status_code == 400
assert "At least one dashboard ID" in resp.text
def test_migrate_source_not_found(self):
"""Non-existent source env returns 404."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [self._make_env("tgt-1")]
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.post("/api/dashboards/migrate", json={
"source_env_id": "src-missing",
"target_env_id": "tgt-1",
"dashboard_ids": [1],
})
assert resp.status_code == 404
assert "Source environment not found" in resp.text
def test_migrate_target_not_found(self):
"""Non-existent target env returns 404."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [self._make_env("src-1")]
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.post("/api/dashboards/migrate", json={
"source_env_id": "src-1",
"target_env_id": "tgt-missing",
"dashboard_ids": [1],
})
assert resp.status_code == 404
assert "Target environment not found" in resp.text
def test_migrate_task_creation_fail(self):
"""Task creation failure returns 503."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [
self._make_env("src-1"),
self._make_env("tgt-1"),
]
mock_task_manager = AsyncMock()
mock_task_manager.create_task.side_effect = Exception("DB down")
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.post("/api/dashboards/migrate", json={
"source_env_id": "src-1",
"target_env_id": "tgt-1",
"dashboard_ids": [1],
})
assert resp.status_code == 503
def test_migrate_with_db_mappings(self):
"""Migration with replace_db_config and db_mappings."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [
self._make_env("src-1"),
self._make_env("tgt-1"),
]
mock_task = MagicMock()
mock_task.id = "task-dbm"
mock_task_manager = AsyncMock()
mock_task_manager.create_task.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.post("/api/dashboards/migrate", json={
"source_env_id": "src-1",
"target_env_id": "tgt-1",
"dashboard_ids": [1],
"replace_db_config": True,
"db_mappings": {"old_db": "new_db"},
})
assert resp.status_code == 200
# ── backup_dashboards ──
class TestBackupDashboards:
"""POST /api/dashboards/backup"""
def test_backup_success(self):
"""Happy path: backup task created."""
env = MagicMock()
env.id = "env-1"
mock_config = MagicMock()
mock_config.get_environments.return_value = [env]
mock_task = MagicMock()
mock_task.id = "task-backup-1"
mock_task_manager = AsyncMock()
mock_task_manager.create_task.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.post("/api/dashboards/backup", json={
"env_id": "env-1",
"dashboard_ids": [10, 20],
})
assert resp.status_code == 200
assert resp.json()["task_id"] == "task-backup-1"
def test_backup_empty_dashboard_ids(self):
"""Empty dashboard_ids returns 400."""
mock_config = MagicMock()
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.post("/api/dashboards/backup", json={
"env_id": "env-1",
"dashboard_ids": [],
})
assert resp.status_code == 400
assert "At least one dashboard ID" in resp.text
def test_backup_env_not_found(self):
"""Non-existent env returns 404."""
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.post("/api/dashboards/backup", json={
"env_id": "env-ghost",
"dashboard_ids": [1],
})
assert resp.status_code == 404
assert "Environment not found" in resp.text
def test_backup_with_schedule(self):
"""Backup with schedule."""
env = MagicMock()
env.id = "env-1"
mock_config = MagicMock()
mock_config.get_environments.return_value = [env]
mock_task = MagicMock()
mock_task.id = "task-sched"
mock_task_manager = AsyncMock()
mock_task_manager.create_task.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.post("/api/dashboards/backup", json={
"env_id": "env-1",
"dashboard_ids": [1, 2],
"schedule": "0 0 * * *",
})
assert resp.status_code == 200
def test_backup_task_creation_fail(self):
"""Task creation failure returns 503."""
env = MagicMock()
env.id = "env-1"
mock_config = MagicMock()
mock_config.get_environments.return_value = [env]
mock_task_manager = AsyncMock()
mock_task_manager.create_task.side_effect = Exception("Queue full")
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.post("/api/dashboards/backup", json={
"env_id": "env-1",
"dashboard_ids": [1],
})
assert resp.status_code == 503
# #endregion Test.Api.DashboardActionRoutes

View File

@@ -0,0 +1,193 @@
# #region Test.Api.DashboardHelpers [C:3] [TYPE Module] [SEMANTICS test,dashboard,helpers]
# @BRIEF Unit tests for dashboard helper functions.
# @RELATION BINDS_TO -> [DashboardHelpers]
# @TEST_EDGE: empty_ref -> 404
# @TEST_EDGE: slug_resolution -> int | None
# @TEST_EDGE: numeric_ref_fallback -> int
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
# ── Deprecated sync functions ──
class TestDeprecatedSyncFunctions:
"""_find_dashboard_id_by_slug and _resolve_dashboard_id_from_ref (sync) raise RuntimeError."""
def test_find_dashboard_id_by_slug_deprecated(self):
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug
with pytest.raises(RuntimeError, match="deprecated"):
_find_dashboard_id_by_slug(MagicMock(), "test-slug")
def test_resolve_dashboard_id_from_ref_multiple_defs(self):
"""The module has two defs of _resolve_dashboard_id_from_ref. The second wins.
It should raise HTTPException(404) for unknown refs, not RuntimeError."""
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref
from fastapi import HTTPException as HE
client = MagicMock()
client.get_dashboards_page.return_value = (0, [])
with pytest.raises(HE) as exc:
_resolve_dashboard_id_from_ref("unknown-ref", client)
assert exc.value.status_code == 404
# ── _find_dashboard_id_by_slug_async ──
class TestFindDashboardIdBySlugAsync:
"""_find_dashboard_id_by_slug_async"""
@pytest.mark.asyncio
async def test_found_by_slug(self):
client = AsyncMock()
client.get_dashboards_page.return_value = (1, [{"id": 42}])
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug_async
result = await _find_dashboard_id_by_slug_async(client, "my-slug")
assert result == 42
@pytest.mark.asyncio
async def test_not_found(self):
client = AsyncMock()
client.get_dashboards_page.return_value = (0, [])
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug_async
result = await _find_dashboard_id_by_slug_async(client, "ghost-slug")
assert result is None
@pytest.mark.asyncio
async def test_exception_then_second_query(self):
client = AsyncMock()
# First query raises, second succeeds
client.get_dashboards_page.side_effect = [
Exception("First query failed"),
(1, [{"id": 99}]),
]
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug_async
result = await _find_dashboard_id_by_slug_async(client, "retry-slug")
assert result == 99
@pytest.mark.asyncio
async def test_all_queries_fail(self):
client = AsyncMock()
client.get_dashboards_page.side_effect = Exception("All failed")
from src.api.routes.dashboards._helpers import _find_dashboard_id_by_slug_async
result = await _find_dashboard_id_by_slug_async(client, "fail-slug")
assert result is None
# ── _resolve_dashboard_id_from_ref_async ──
class TestResolveDashboardIdFromRefAsync:
"""_resolve_dashboard_id_from_ref_async"""
@pytest.mark.asyncio
async def test_numeric_ref(self):
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
result = await _resolve_dashboard_id_from_ref_async("123", AsyncMock())
assert result == 123
@pytest.mark.asyncio
async def test_slug_ref(self):
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
client = AsyncMock()
client.get_dashboards_page.return_value = (1, [{"id": 42}])
result = await _resolve_dashboard_id_from_ref_async("my-dash", client)
assert result == 42
@pytest.mark.asyncio
async def test_empty_ref(self):
from fastapi import HTTPException
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref_async("", AsyncMock())
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_slug_not_found(self):
from fastapi import HTTPException
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
client = AsyncMock()
client.get_dashboards_page.return_value = (0, [])
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref_async("ghost", client)
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_none_ref(self):
from fastapi import HTTPException
from src.api.routes.dashboards._helpers import _resolve_dashboard_id_from_ref_async
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref_async(None, AsyncMock()) # type: ignore
assert exc.value.status_code == 404
# ── _normalize_filter_values ──
class TestNormalizeFilterValues:
"""_normalize_filter_values"""
def test_normalize_returns_lowercase(self):
from src.api.routes.dashboards._helpers import _normalize_filter_values
assert _normalize_filter_values(["HELLO", "World"]) == ["hello", "world"]
def test_normalize_empty_list(self):
from src.api.routes.dashboards._helpers import _normalize_filter_values
assert _normalize_filter_values([]) == []
def test_normalize_none(self):
from src.api.routes.dashboards._helpers import _normalize_filter_values
assert _normalize_filter_values(None) == []
def test_normalize_removes_empty_strings(self):
from src.api.routes.dashboards._helpers import _normalize_filter_values
assert _normalize_filter_values(["a", "", " ", "b"]) == ["a", "b"]
def test_normalize_strips_whitespace(self):
from src.api.routes.dashboards._helpers import _normalize_filter_values
assert _normalize_filter_values([" Foo Bar "]) == ["foo bar"]
# ── _dashboard_git_filter_value ──
class TestDashboardGitFilterValue:
"""_dashboard_git_filter_value"""
def test_no_repo(self):
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
assert _dashboard_git_filter_value({"git_status": {"has_repo": False}}) == "no_repo"
def test_no_repo_status(self):
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
assert _dashboard_git_filter_value({"git_status": {"sync_status": "NO_REPO"}}) == "no_repo"
def test_diff(self):
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
assert _dashboard_git_filter_value({"git_status": {"sync_status": "DIFF", "has_repo": True}}) == "diff"
def test_ok(self):
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
assert _dashboard_git_filter_value({"git_status": {"sync_status": "OK", "has_repo": True}}) == "ok"
def test_error(self):
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
assert _dashboard_git_filter_value({"git_status": {"sync_status": "ERROR", "has_repo": True}}) == "error"
def test_missing_git_status(self):
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
assert _dashboard_git_filter_value({}) == "pending"
def test_none_git_status(self):
from src.api.routes.dashboards._helpers import _dashboard_git_filter_value
assert _dashboard_git_filter_value({"git_status": None}) == "pending"
# #endregion Test.Api.DashboardHelpers

View File

@@ -0,0 +1,255 @@
# #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=[])],
)
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:
defaults.update(overrides)
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()
from src.dependencies import get_config_manager, get_db, get_resource_service, get_task_manager
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")
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
# #endregion Test.Api.DashboardListingRoutes

View File

@@ -0,0 +1,278 @@
# #region Test.Api.DashboardProjection [C:3] [TYPE Module] [SEMANTICS test,dashboard,projection]
# @BRIEF Unit tests for dashboard projection/profile helpers.
# @RELATION BINDS_TO -> [DashboardProjection]
# @TEST_EDGE: owner_normalization
# @TEST_EDGE: profile_filter_binding
# @TEST_EDGE: task_matching
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
# ── _normalize_actor_alias_token ──
class TestNormalizeActorAliasToken:
def test_none_value(self):
from src.api.routes.dashboards._projection import _normalize_actor_alias_token
assert _normalize_actor_alias_token(None) is None
def test_empty_string(self):
from src.api.routes.dashboards._projection import _normalize_actor_alias_token
assert _normalize_actor_alias_token("") is None
def test_whitespace(self):
from src.api.routes.dashboards._projection import _normalize_actor_alias_token
assert _normalize_actor_alias_token(" ") is None
def test_normalized(self):
from src.api.routes.dashboards._projection import _normalize_actor_alias_token
assert _normalize_actor_alias_token(" Alice ") == "alice"
# ── _normalize_owner_display_token ──
class TestNormalizeOwnerDisplayToken:
def test_none(self):
from src.api.routes.dashboards._projection import _normalize_owner_display_token
assert _normalize_owner_display_token(None) is None
def test_dict_with_username(self):
from src.api.routes.dashboards._projection import _normalize_owner_display_token
assert _normalize_owner_display_token({"username": "Alice"}) == "Alice"
def test_dict_with_full_name(self):
from src.api.routes.dashboards._projection import _normalize_owner_display_token
assert _normalize_owner_display_token({"full_name": "Alice Smith"}) == "Alice Smith"
def test_dict_empty(self):
from src.api.routes.dashboards._projection import _normalize_owner_display_token
assert _normalize_owner_display_token({}) is None
def test_string(self):
from src.api.routes.dashboards._projection import _normalize_owner_display_token
assert _normalize_owner_display_token("Alice") == "Alice"
def test_int(self):
from src.api.routes.dashboards._projection import _normalize_owner_display_token
assert _normalize_owner_display_token(42) is None
# ── _normalize_dashboard_owner_values ──
class TestNormalizeDashboardOwnerValues:
def test_none(self):
from src.api.routes.dashboards._projection import _normalize_dashboard_owner_values
assert _normalize_dashboard_owner_values(None) is None
def test_list_of_dicts(self):
from src.api.routes.dashboards._projection import _normalize_dashboard_owner_values
result = _normalize_dashboard_owner_values([
{"username": "Alice"},
{"username": "Bob"},
])
assert result == ["Alice", "Bob"]
def test_single_dict(self):
from src.api.routes.dashboards._projection import _normalize_dashboard_owner_values
result = _normalize_dashboard_owner_values({"username": "Charlie"})
assert result == ["Charlie"]
def test_duplicates_removed(self):
from src.api.routes.dashboards._projection import _normalize_dashboard_owner_values
result = _normalize_dashboard_owner_values([
{"username": "Alice"},
{"username": "Alice"},
])
assert result == ["Alice"]
# ── _project_dashboard_response_items ──
class TestProjectDashboardResponseItems:
def test_owners_normalized(self):
from src.api.routes.dashboards._projection import _project_dashboard_response_items
dashboards = [
{"id": 1, "title": "Main", "owners": [{"username": "Alice"}]},
]
result = _project_dashboard_response_items(dashboards)
assert result[0]["owners"] == ["Alice"]
def test_empty_list(self):
from src.api.routes.dashboards._projection import _project_dashboard_response_items
assert _project_dashboard_response_items([]) == []
# ── _get_profile_filter_binding ──
class TestGetProfileFilterBinding:
def test_uses_get_dashboard_filter_binding(self):
from src.api.routes.dashboards._projection import _get_profile_filter_binding
profile_service = MagicMock()
profile_service.get_dashboard_filter_binding.return_value = {
"superset_username": "alice",
"superset_username_normalized": "alice",
"show_only_my_dashboards": True,
"show_only_slug_dashboards": False,
}
result = _get_profile_filter_binding(profile_service, MagicMock())
assert result["superset_username"] == "alice"
assert result["show_only_my_dashboards"] is True
def test_uses_get_my_preference_fallback(self):
from src.api.routes.dashboards._projection import _get_profile_filter_binding
profile_service = MagicMock()
profile_service.get_dashboard_filter_binding = None
pref = MagicMock()
pref.preference.superset_username = "bob"
pref.preference.superset_username_normalized = "bob"
pref.preference.show_only_my_dashboards = True
pref.preference.show_only_slug_dashboards = False
profile_service.get_my_preference.return_value = pref
result = _get_profile_filter_binding(profile_service, MagicMock())
assert result["superset_username"] == "bob"
def test_no_methods_returns_defaults(self):
from src.api.routes.dashboards._projection import _get_profile_filter_binding
profile_service = MagicMock(spec=[]) # no relevant methods
result = _get_profile_filter_binding(profile_service, MagicMock())
assert result["superset_username"] is None
assert result["show_only_my_dashboards"] is False
# ── _resolve_profile_actor_aliases ──
class TestResolveProfileActorAliases:
@pytest.mark.asyncio
async def test_empty_username(self):
from src.api.routes.dashboards._projection import _resolve_profile_actor_aliases
result = await _resolve_profile_actor_aliases(MagicMock(), "")
assert result == []
@pytest.mark.asyncio
async def test_lookup_success(self):
from src.api.routes.dashboards._projection import _resolve_profile_actor_aliases
mock_adapter = AsyncMock()
mock_adapter.get_users_page.return_value = {
"items": [
{"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_exception(self):
from src.api.routes.dashboards._projection import _resolve_profile_actor_aliases
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.side_effect = Exception("Connection error")
result = await _resolve_profile_actor_aliases(mock_env, "alice")
assert result == ["alice"]
# ── _matches_dashboard_actor_aliases ──
class TestMatchesDashboardActorAliases:
def test_matches(self):
from src.api.routes.dashboards._projection import _matches_dashboard_actor_aliases
profile_service = MagicMock()
profile_service.matches_dashboard_actor.return_value = True
assert _matches_dashboard_actor_aliases(profile_service, ["alice"], [], "bob") is True
def test_no_match(self):
from src.api.routes.dashboards._projection import _matches_dashboard_actor_aliases
profile_service = MagicMock()
profile_service.matches_dashboard_actor.return_value = False
assert _matches_dashboard_actor_aliases(profile_service, ["alice"], [], None) is False
def test_first_match_short_circuits(self):
from src.api.routes.dashboards._projection import _matches_dashboard_actor_aliases
profile_service = MagicMock()
profile_service.matches_dashboard_actor.side_effect = [False, True]
assert _matches_dashboard_actor_aliases(profile_service, ["alice", "bob"], [], None) is True
assert profile_service.matches_dashboard_actor.call_count == 2
# ── _task_matches_dashboard ──
class TestTaskMatchesDashboard:
def test_llm_validation_matches(self):
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "llm_dashboard_validation"
task.params = {"dashboard_id": 42, "environment_id": "env-1"}
assert _task_matches_dashboard(task, 42, "env-1") is True
def test_llm_validation_wrong_dashboard(self):
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "llm_dashboard_validation"
task.params = {"dashboard_id": 99}
assert _task_matches_dashboard(task, 42, None) is False
def test_llm_validation_no_env(self):
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "llm_dashboard_validation"
task.params = {"dashboard_id": 42}
assert _task_matches_dashboard(task, 42, None) is True
def test_backup_matches(self):
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "superset-backup"
task.params = {"dashboard_ids": [1, 42, 3], "environment_id": "env-1"}
assert _task_matches_dashboard(task, 42, "env-1") is True
def test_backup_dashboards_key(self):
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "superset-backup"
task.params = {"dashboards": [42], "env": "env-1"}
assert _task_matches_dashboard(task, 42, "env-1") is True
def test_unrelated_plugin(self):
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "some-other-plugin"
assert _task_matches_dashboard(task, 42, "env-1") is False
def test_backup_wrong_dashboard(self):
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "superset-backup"
task.params = {"dashboard_ids": [1, 2], "environment_id": "env-1"}
assert _task_matches_dashboard(task, 42, "env-1") is False
def test_backup_wrong_env(self):
from src.api.routes.dashboards._projection import _task_matches_dashboard
task = MagicMock()
task.plugin_id = "superset-backup"
task.params = {"dashboard_ids": [42], "environment_id": "env-1"}
assert _task_matches_dashboard(task, 42, "env-2") is False
# #endregion Test.Api.DashboardProjection

View File

@@ -0,0 +1,263 @@
# #region Test.Api.Environments [C:3] [TYPE Module] [SEMANTICS test,environments,api]
# @BRIEF Unit tests for environments API routes — list, schedule, databases.
# @RELATION BINDS_TO -> [EnvironmentsApi]
# @TEST_EDGE: environment_not_found -> 404
# @TEST_EDGE: empty_list -> 200 empty
# @TEST_EDGE: superset_connection_fail -> 500
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 = "Production",
url: str = "https://superset.example.com",
stage: str = "PROD",
is_production: bool = True,
backup_enabled: bool = True,
backup_cron: str = "0 0 * * *",
):
env = MagicMock()
env.id = id
env.name = name
env.url = url
env.stage = stage
env.is_production = is_production
env.backup_schedule = MagicMock()
env.backup_schedule.enabled = backup_enabled
env.backup_schedule.cron_expression = backup_cron
return env
def _make_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.environments import router
from src.dependencies import get_current_user, 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_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_environments ──
class TestGetEnvironments:
"""GET /api/environments"""
def test_list_environments_success(self):
"""Happy path: returns list of environments."""
mock_config = MagicMock()
mock_config.get_environments.return_value = [
_make_mock_env(id="env-1", name="Dev", url="https://dev.superset.example.com", stage="DEV", is_production=False),
_make_mock_env(id="env-2", name="Prod", url="https://prod.superset.example.com/api/v1", stage="PROD", is_production=True),
]
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/environments")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["id"] == "env-1"
assert data[0]["url"] == "https://dev.superset.example.com"
assert data[0]["stage"] == "DEV"
assert data[1]["stage"] == "PROD"
assert data[1]["is_production"] is True
def test_list_environments_not_a_list(self):
"""Non-list environments returns empty list."""
mock_config = MagicMock()
mock_config.get_environments.return_value = None
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/environments")
assert resp.status_code == 200
assert resp.json() == []
def test_list_environments_empty(self):
"""Empty environments list returns 200 with empty array."""
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/environments")
assert resp.status_code == 200
assert resp.json() == []
def test_list_environments_no_backup_schedule(self):
"""Environment without backup schedule returns None schedule."""
env = _make_mock_env(backup_enabled=False)
env.backup_schedule = None
mock_config = MagicMock()
mock_config.get_environments.return_value = [env]
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/environments")
assert resp.status_code == 200
assert resp.json()[0]["backup_schedule"] is None
# ── update_environment_schedule ──
class TestUpdateEnvironmentSchedule:
"""PUT /api/environments/{id}/schedule"""
def test_update_schedule_success(self):
"""Happy path: schedule updated."""
env = _make_mock_env(id="env-1")
mock_config = MagicMock()
mock_config.get_environments.return_value = [env]
mock_scheduler = MagicMock()
from src.dependencies import get_config_manager, get_scheduler_service
client = _make_client({
get_config_manager: lambda: mock_config,
get_scheduler_service: lambda: mock_scheduler,
})
resp = client.put("/api/environments/env-1/schedule", json={
"enabled": True,
"cron_expression": "0 6 * * *",
})
assert resp.status_code == 200
assert resp.json()["message"] == "Schedule updated successfully"
assert env.backup_schedule.cron_expression == "0 6 * * *"
mock_config.update_environment.assert_called_once_with("env-1", env)
mock_scheduler.load_schedules.assert_called_once()
def test_update_schedule_invalid_cron(self):
"""Invalid cron expression returns 422."""
from src.dependencies import get_config_manager, get_scheduler_service
client = _make_client({
get_config_manager: lambda: MagicMock(),
get_scheduler_service: lambda: MagicMock(),
})
resp = client.put("/api/environments/env-1/schedule", json={
"enabled": True,
"cron_expression": "not-a-cron",
})
assert resp.status_code == 422
def test_update_schedule_env_not_found(self):
"""Non-existent environment returns 404."""
mock_config = MagicMock()
mock_config.get_environments.return_value = []
from src.dependencies import get_config_manager, get_scheduler_service
client = _make_client({
get_config_manager: lambda: mock_config,
get_scheduler_service: lambda: MagicMock(),
})
resp = client.put("/api/environments/env-999/schedule", json={
"enabled": True,
"cron_expression": "0 0 * * *",
})
assert resp.status_code == 404
# ── get_environment_databases ──
class TestGetEnvironmentDatabases:
"""GET /api/environments/{id}/databases"""
def test_get_databases_success(self):
"""Happy path: returns database list."""
env = _make_mock_env(id="env-1")
mock_config = MagicMock()
mock_config.get_environments.return_value = [env]
mock_client = AsyncMock()
mock_client.get_databases_summary.return_value = [
{"uuid": "db-1", "database_name": "sales", "engine": "postgresql"},
]
with patch("src.api.routes.environments.AsyncSupersetClient", return_value=mock_client):
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/environments/env-1/databases")
assert resp.status_code == 200
assert isinstance(resp.json(), list)
assert resp.json()[0]["database_name"] == "sales"
def test_get_databases_env_not_found(self):
"""Non-existent environment returns 404."""
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/environments/env-999/databases")
assert resp.status_code == 404
def test_get_databases_connection_fail(self):
"""Superset connection failure returns 500."""
env = _make_mock_env(id="env-1")
mock_config = MagicMock()
mock_config.get_environments.return_value = [env]
mock_client = AsyncMock()
mock_client.get_databases_summary.side_effect = Exception("Connection refused")
with patch("src.api.routes.environments.AsyncSupersetClient", return_value=mock_client):
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/environments/env-1/databases")
assert resp.status_code == 500
assert "Failed to fetch databases" in resp.text
# ── URL normalization ──
class TestNormalizeSupersetEnvUrl:
"""_normalize_superset_env_url"""
def test_normalize_removes_api_v1(self):
from src.api.routes.environments import _normalize_superset_env_url
assert _normalize_superset_env_url("https://superset.example.com/api/v1") == "https://superset.example.com"
def test_normalize_strips_trailing_slash(self):
from src.api.routes.environments import _normalize_superset_env_url
assert _normalize_superset_env_url("https://superset.example.com/") == "https://superset.example.com"
def test_normalize_empty_string(self):
from src.api.routes.environments import _normalize_superset_env_url
assert _normalize_superset_env_url("") == ""
def test_normalize_noop(self):
from src.api.routes.environments import _normalize_superset_env_url
assert _normalize_superset_env_url("https://superset.example.com") == "https://superset.example.com"
# #endregion Test.Api.Environments

View File

@@ -0,0 +1,302 @@
# #region Test.Api.GitConfigRoutes [C:3] [TYPE Module] [SEMANTICS test,git,config,routes]
# @BRIEF Unit tests for Git config API routes — CRUD + test connection.
# @RELATION BINDS_TO -> [GitConfigRoutes]
# @TEST_EDGE: config_not_found -> 404
# @TEST_EDGE: connection_fail -> 400
# @TEST_EDGE: pat_masking
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_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.git._config_routes import router
from src.core.database import get_db
from src.dependencies import get_current_user, has_permission
from src.schemas.auth import User, RoleSchema
app = FastAPI()
# Git routers have no prefix — added via app.include_router in app.py
app.include_router(router, prefix="/api/git")
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_db: 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)
def _make_mock_config(
id: str = "cfg-1",
name: str = "My Git",
provider: str = "GITHUB",
url: str = "https://github.com",
pat: str = "ghp_secret123",
last_validated: str = "2024-01-01T00:00:00",
):
from src.models.git import GitStatus
config = MagicMock()
config.id = id
config.name = name
config.provider = provider
config.url = url
config.pat = pat
config.status = GitStatus.CONNECTED
config.last_validated = last_validated
config.default_repository = None
config.default_branch = "main"
return config
# ── get_git_configs ──
class TestGetGitConfigs:
"""GET /api/git/config"""
def test_list_configs_success(self):
"""Happy path: returns masked configs."""
from src.core.database import get_db
mock_db = MagicMock()
mock_db.query.return_value.all.return_value = [_make_mock_config()]
client = _make_client({get_db: lambda: mock_db})
resp = client.get("/api/git/config")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert data[0]["pat"] == "********"
def test_list_configs_empty(self):
"""Empty list returns 200."""
from src.core.database import get_db
mock_db = MagicMock()
mock_db.query.return_value.all.return_value = []
client = _make_client({get_db: lambda: mock_db})
resp = client.get("/api/git/config")
assert resp.status_code == 200
assert resp.json() == []
# ── create_git_config ──
class TestCreateGitConfig:
"""POST /api/git/config"""
def test_create_config_success(self):
"""Happy path: config created."""
from src.core.database import get_db
mock_db = MagicMock()
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/api/git/config", json={
"name": "New Git",
"provider": "GITHUB",
"url": "https://github.com/org",
"pat": "ghp_newtoken",
"default_branch": "main",
})
assert resp.status_code == 200
# ── update_git_config ──
class TestUpdateGitConfig:
"""PUT /api/git/config/{config_id}"""
def test_update_config_success(self):
"""Happy path: config updated."""
from src.core.database import get_db
mock_db = MagicMock()
existing = _make_mock_config(id="cfg-1", pat="ghp_secret")
mock_db.query.return_value.filter.return_value.first.return_value = existing
client = _make_client({get_db: lambda: mock_db})
resp = client.put("/api/git/config/cfg-1", json={"name": "Updated Git"})
assert resp.status_code == 200
assert existing.name == "Updated Git"
assert resp.json()["pat"] == "********"
def test_update_config_not_found(self):
"""Non-existent config returns 404."""
from src.core.database import get_db
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
client = _make_client({get_db: lambda: mock_db})
resp = client.put("/api/git/config/cfg-999", json={"name": "Ghost"})
assert resp.status_code == 404
def test_update_config_preserves_pat(self):
"""When pat is ********, existing PAT preserved."""
from src.core.database import get_db
mock_db = MagicMock()
existing = _make_mock_config(id="cfg-1", pat="ghp_secret")
mock_db.query.return_value.filter.return_value.first.return_value = existing
client = _make_client({get_db: lambda: mock_db})
resp = client.put("/api/git/config/cfg-1", json={"pat": "********"})
assert resp.status_code == 200
assert existing.pat == "ghp_secret"
# ── delete_git_config ──
class TestDeleteGitConfig:
"""DELETE /api/git/config/{config_id}"""
def test_delete_config_success(self):
"""Happy path: config deleted."""
from src.core.database import get_db
mock_db = MagicMock()
existing = _make_mock_config()
mock_db.query.return_value.filter.return_value.first.return_value = existing
client = _make_client({get_db: lambda: mock_db})
resp = client.delete("/api/git/config/cfg-1")
assert resp.status_code == 200
assert resp.json()["status"] == "success"
mock_db.delete.assert_called_once_with(existing)
mock_db.commit.assert_called_once()
def test_delete_config_not_found(self):
"""Non-existent config returns 404."""
from src.core.database import get_db
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
client = _make_client({get_db: lambda: mock_db})
resp = client.delete("/api/git/config/cfg-999")
assert resp.status_code == 404
# ── test_git_config ──
class TestTestGitConfig:
"""POST /api/git/config/test"""
def test_connection_success(self):
"""Success returns 200."""
from src.core.database import get_db
mock_db = MagicMock()
mock_service = AsyncMock()
mock_service.test_connection.return_value = True
with patch("src.api.routes.git._config_routes.get_git_service", return_value=mock_service):
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/api/git/config/test", json={
"name": "Test",
"provider": "GITHUB",
"url": "https://github.com",
"pat": "ghp_test",
"default_branch": "main",
})
assert resp.status_code == 200
assert resp.json()["status"] == "success"
def test_connection_fail(self):
"""Failure returns 400."""
from src.core.database import get_db
mock_db = MagicMock()
mock_service = AsyncMock()
mock_service.test_connection.return_value = False
with patch("src.api.routes.git._config_routes.get_git_service", return_value=mock_service):
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/api/git/config/test", json={
"name": "Test",
"provider": "GITHUB",
"url": "https://github.com",
"pat": "ghp_fail",
})
assert resp.status_code == 400
assert "Connection failed" in resp.text
def test_connection_with_masked_pat_and_config_id(self):
"""Masked PAT resolved from existing config by config_id."""
from src.core.database import get_db
mock_db = MagicMock()
existing = _make_mock_config(id="cfg-1", pat="ghp_resolved")
# First filter call (by id) returns existing
mock_q = MagicMock()
mock_f = MagicMock()
mock_f.first.return_value = existing
mock_q.filter.return_value = mock_f
mock_db.query.return_value = mock_q
mock_service = AsyncMock()
mock_service.test_connection.return_value = True
with patch("src.api.routes.git._config_routes.get_git_service", return_value=mock_service):
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/api/git/config/test", json={
"name": "Test",
"provider": "GITHUB",
"url": "https://github.com",
"pat": "********",
"config_id": "cfg-1",
})
assert resp.status_code == 200
mock_service.test_connection.assert_called_with("GITHUB", "https://github.com", "ghp_resolved")
def test_connection_with_masked_pat_and_url_fallback(self):
"""Masked PAT resolved by URL match when config_id not provided."""
from src.core.database import get_db
mock_db = MagicMock()
existing = _make_mock_config(id="cfg-2", pat="ghp_url_resolved")
# For test_git_config: first query by id returns None, second by url returns existing
class _MockFilter:
def __init__(self, return_values):
self.return_values = return_values
self.call_count = 0
def first(self):
val = self.return_values[self.call_count] if self.call_count < len(self.return_values) else self.return_values[-1]
self.call_count += 1
return val
mock_q = MagicMock()
mock_f = _MockFilter([None, existing])
mock_q.filter.return_value = mock_f
mock_db.query.return_value = mock_q
mock_service = AsyncMock()
mock_service.test_connection.return_value = True
with patch("src.api.routes.git._config_routes.get_git_service", return_value=mock_service):
client = _make_client({get_db: lambda: mock_db})
resp = client.post("/api/git/config/test", json={
"name": "Test",
"provider": "GITHUB",
"url": "https://github.com",
"pat": "********",
})
assert resp.status_code == 200
mock_service.test_connection.assert_called_with("GITHUB", "https://github.com", "ghp_url_resolved")
# #endregion Test.Api.GitConfigRoutes

View File

@@ -0,0 +1,41 @@
# #region Test.Api.GitDeps [C:2] [TYPE Module] [SEMANTICS test,git,deps]
# @BRIEF Unit tests for git dependency helper.
# @RELATION BINDS_TO -> [GitDeps]
# @TEST_EDGE: monkeypatch_resolution
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
class TestGetGitService:
"""get_git_service resolves from sys.modules at call time."""
def test_get_git_service_resolves_correctly(self):
"""Verify it resolves from sys.modules."""
import sys as sys_mod
from src.api.routes.git._deps import get_git_service
from src.api.routes import git as git_routes
mock_service = MagicMock()
# Monkeypatch via sys.modules
sys_mod.modules["src.api.routes.git"].git_service = mock_service
try:
result = get_git_service()
assert result is mock_service
finally:
# Restore
sys_mod.modules["src.api.routes.git"].git_service = git_routes.git_service
def test_max_repository_status_batch(self):
"""Guard value is 50."""
from src.api.routes.git._deps import MAX_REPOSITORY_STATUS_BATCH
assert MAX_REPOSITORY_STATUS_BATCH == 50
# #endregion Test.Api.GitDeps

View File

@@ -0,0 +1,87 @@
# #region Test.Api.GitEnvironmentRoutes [C:2] [TYPE Module] [SEMANTICS test,git,environment,routes]
# @BRIEF Unit tests for Git environment routes.
# @RELATION BINDS_TO -> [GitEnvironmentRoutes]
# @TEST_EDGE: empty_list -> 200
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 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_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.git._environment_routes import router
from src.dependencies import get_current_user, has_permission
from src.schemas.auth import User, RoleSchema
app = FastAPI()
app.include_router(router, prefix="/api/git")
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_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)
class TestGetEnvironments:
"""GET /api/git/environments"""
def test_list_environments_success(self):
"""Happy path: returns deployment environments."""
mock_config = MagicMock()
env1 = MagicMock()
env1.id = "env-1"
env1.name = "Production"
env1.url = "https://superset.prod.com"
env2 = MagicMock()
env2.id = "env-2"
env2.name = "Staging"
env2.url = "https://superset.staging.com"
mock_config.get_environments.return_value = [env1, env2]
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/git/environments")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["id"] == "env-1"
assert data[0]["name"] == "Production"
assert data[0]["superset_url"] == "https://superset.prod.com"
assert data[0]["is_active"] is True
def test_list_environments_empty(self):
"""Empty list returns 200."""
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/git/environments")
assert resp.status_code == 200
assert resp.json() == []
# #endregion Test.Api.GitEnvironmentRoutes

View File

@@ -0,0 +1,481 @@
# #region Test.Api.GitHelpers [C:3] [TYPE Module] [SEMANTICS test,git,helpers]
# @BRIEF Unit tests for git helper functions.
# @RELATION BINDS_TO -> [GitHelpers]
# @TEST_EDGE: no_repo_path
# @TEST_EDGE: config_not_found -> 404
# @TEST_EDGE: slug_not_found -> 404
# @TEST_EDGE: empty_ref -> 400
import os
import sys
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
_src = str(Path(__file__).resolve().parent.parent.parent / "src")
if _src not in sys.path:
sys.path.insert(0, _src)
# ── _build_no_repo_status_payload ──
class TestBuildNoRepoStatusPayload:
def test_payload_structure(self):
from src.api.routes.git._helpers import _build_no_repo_status_payload
payload = _build_no_repo_status_payload()
assert payload["sync_status"] == "NO_REPO"
assert payload["has_repo"] is False
assert payload["is_dirty"] is False
assert payload["untracked_files"] == []
# ── _handle_unexpected_git_route_error ──
class TestHandleUnexpectedGitRouteError:
def test_raises_http_500(self):
from fastapi import HTTPException
from src.api.routes.git._helpers import _handle_unexpected_git_route_error
with pytest.raises(HTTPException) as exc:
_handle_unexpected_git_route_error("test_route", RuntimeError("boom"))
assert exc.value.status_code == 500
assert "test_route failed" in exc.value.detail
# ── _resolve_repository_status ──
class TestResolveRepositoryStatus:
@pytest.mark.asyncio
async def test_repo_path_exists(self):
mock_service = MagicMock()
mock_service._get_repo_path = AsyncMock(return_value="/tmp/some-repo")
mock_service.get_status = AsyncMock(return_value={"sync_status": "OK"})
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
patch("os.path.exists", return_value=True):
from src.api.routes.git._helpers import _resolve_repository_status
result = await _resolve_repository_status(42)
assert result["sync_status"] == "OK"
@pytest.mark.asyncio
async def test_repo_path_missing(self):
mock_service = MagicMock()
mock_service._get_repo_path = AsyncMock(return_value="/nonexistent")
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
patch("os.path.exists", return_value=False):
from src.api.routes.git._helpers import _resolve_repository_status
result = await _resolve_repository_status(42)
assert result["sync_status"] == "NO_REPO"
@pytest.mark.asyncio
async def test_get_status_returns_404(self):
from fastapi import HTTPException
mock_service = MagicMock()
mock_service._get_repo_path = AsyncMock(return_value="/tmp/repo")
mock_service.get_status = AsyncMock(side_effect=HTTPException(status_code=404, detail="No repo"))
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
patch("os.path.exists", return_value=True):
from src.api.routes.git._helpers import _resolve_repository_status
result = await _resolve_repository_status(42)
assert result["sync_status"] == "NO_REPO"
@pytest.mark.asyncio
async def test_get_status_raises_other_http(self):
from fastapi import HTTPException
mock_service = MagicMock()
mock_service._get_repo_path = AsyncMock(return_value="/tmp/repo")
mock_service.get_status = AsyncMock(side_effect=HTTPException(status_code=500, detail="Server error"))
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
patch("os.path.exists", return_value=True):
from src.api.routes.git._helpers import _resolve_repository_status
with pytest.raises(HTTPException) as exc:
await _resolve_repository_status(42)
assert exc.value.status_code == 500
# ── _get_git_config_or_404 ──
class TestGetGitConfigOr404:
def test_config_found(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = MagicMock(id="cfg-1")
from src.api.routes.git._helpers import _get_git_config_or_404
result = _get_git_config_or_404(mock_db, "cfg-1")
assert result is not None
def test_config_not_found(self):
from fastapi import HTTPException
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
from src.api.routes.git._helpers import _get_git_config_or_404
with pytest.raises(HTTPException) as exc:
_get_git_config_or_404(mock_db, "cfg-missing")
assert exc.value.status_code == 404
# ── _find_dashboard_id_by_slug (sync) ──
class TestFindDashboardIdBySlug:
@pytest.mark.asyncio
async def test_found(self):
client = AsyncMock()
client.get_dashboards_page.return_value = (1, [{"id": 42}])
from src.api.routes.git._helpers import _find_dashboard_id_by_slug
result = await _find_dashboard_id_by_slug(client, "my-slug")
assert result == 42
@pytest.mark.asyncio
async def test_not_found(self):
client = AsyncMock()
client.get_dashboards_page.return_value = (0, [])
from src.api.routes.git._helpers import _find_dashboard_id_by_slug
result = await _find_dashboard_id_by_slug(client, "ghost")
assert result is None
@pytest.mark.asyncio
async def test_exception_then_second_query(self):
client = AsyncMock()
client.get_dashboards_page.side_effect = [
Exception("Fail"),
(1, [{"id": 99}]),
]
from src.api.routes.git._helpers import _find_dashboard_id_by_slug
result = await _find_dashboard_id_by_slug(client, "retry")
assert result == 99
# ── _resolve_dashboard_id_from_ref (sync) ──
class TestResolveDashboardIdFromRef:
@pytest.mark.asyncio
async def test_numeric_ref(self):
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
result = await _resolve_dashboard_id_from_ref("123", MagicMock())
assert result == 123
@pytest.mark.asyncio
async def test_slug_ref(self):
mock_config = MagicMock()
env = MagicMock()
env.id = "env-1"
mock_config.get_environments.return_value = [env]
client = MagicMock()
client.get_dashboards_page.return_value = (1, [{"id": 42}])
with patch("src.api.routes.git._helpers.SupersetClient", return_value=client):
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
result = await _resolve_dashboard_id_from_ref("my-slug", mock_config, "env-1")
assert result == 42
@pytest.mark.asyncio
async def test_empty_ref(self):
from fastapi import HTTPException
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref("", MagicMock())
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_slug_no_env_id(self):
from fastapi import HTTPException
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref("slug", MagicMock())
assert exc.value.status_code == 400
assert "env_id is required" in exc.value.detail
@pytest.mark.asyncio
async def test_env_not_found(self):
from fastapi import HTTPException
mock_config = MagicMock()
mock_config.get_environments.return_value = []
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref("slug", mock_config, "env-ghost")
assert exc.value.status_code == 404
@pytest.mark.asyncio
async def test_slug_not_found(self):
from fastapi import HTTPException
mock_config = MagicMock()
env = MagicMock()
env.id = "env-1"
mock_config.get_environments.return_value = [env]
client = MagicMock()
client.get_dashboards_page.return_value = (0, [])
with patch("src.api.routes.git._helpers.SupersetClient", return_value=client):
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref("ghost-slug", mock_config, "env-1")
assert exc.value.status_code == 404
# ── _find_dashboard_id_by_slug_async ──
class TestFindDashboardIdBySlugAsync:
@pytest.mark.asyncio
async def test_found(self):
client = AsyncMock()
client.get_dashboards_page.return_value = (1, [{"id": 42}])
from src.api.routes.git._helpers import _find_dashboard_id_by_slug_async
result = await _find_dashboard_id_by_slug_async(client, "slug")
assert result == 42
@pytest.mark.asyncio
async def test_not_found(self):
client = AsyncMock()
client.get_dashboards_page.return_value = (0, [])
from src.api.routes.git._helpers import _find_dashboard_id_by_slug_async
result = await _find_dashboard_id_by_slug_async(client, "ghost")
assert result is None
# ── _resolve_dashboard_id_from_ref_async ──
class TestResolveDashboardIdFromRefAsync:
@pytest.mark.asyncio
async def test_numeric_ref(self):
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
result = await _resolve_dashboard_id_from_ref_async("123", MagicMock())
assert result == 123
@pytest.mark.asyncio
async def test_slug_ref(self):
mock_config = MagicMock()
env = MagicMock()
env.id = "env-1"
mock_config.get_environments.return_value = [env]
client = AsyncMock()
client.get_dashboards_page.return_value = (1, [{"id": 42}])
client.aclose = AsyncMock()
with patch("src.api.routes.git._helpers.AsyncSupersetClient", return_value=client):
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
result = await _resolve_dashboard_id_from_ref_async("slug", mock_config, "env-1")
assert result == 42
client.aclose.assert_awaited_once()
@pytest.mark.asyncio
async def test_empty_ref(self):
from fastapi import HTTPException
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref_async("", MagicMock())
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_slug_not_found_async(self):
from fastapi import HTTPException
mock_config = MagicMock()
env = MagicMock()
env.id = "env-1"
mock_config.get_environments.return_value = [env]
client = AsyncMock()
client.get_dashboards_page.return_value = (0, [])
client.aclose = AsyncMock()
with patch("src.api.routes.git._helpers.AsyncSupersetClient", return_value=client):
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref_async("ghost", mock_config, "env-1")
assert exc.value.status_code == 404
client.aclose.assert_awaited_once()
@pytest.mark.asyncio
async def test_no_env_id(self):
from fastapi import HTTPException
from src.api.routes.git._helpers import _resolve_dashboard_id_from_ref_async
with pytest.raises(HTTPException) as exc:
await _resolve_dashboard_id_from_ref_async("slug", MagicMock())
assert exc.value.status_code == 400
# ── _resolve_repo_key_from_ref ──
class TestResolveRepoKeyFromRef:
@pytest.mark.asyncio
async def test_slug_ref_returns_slug(self):
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
result = await _resolve_repo_key_from_ref("my-dash", 42, MagicMock())
assert result == "my-dash"
@pytest.mark.asyncio
async def test_numeric_ref_returns_dashboard_id_fallback(self):
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
result = await _resolve_repo_key_from_ref("123", 42, MagicMock())
assert result == "dashboard-42"
@pytest.mark.asyncio
async def test_numeric_ref_with_env_lookup(self):
mock_config = MagicMock()
env = MagicMock()
env.id = "env-1"
mock_config.get_environments.return_value = [env]
client = MagicMock()
client.get_dashboard.return_value = {"result": {"slug": "real-slug"}}
with patch("src.api.routes.git._helpers.SupersetClient", return_value=client):
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
result = await _resolve_repo_key_from_ref("123", 42, mock_config, "env-1")
assert result == "real-slug"
@pytest.mark.asyncio
async def test_env_lookup_fails_uses_fallback(self):
mock_config = MagicMock()
env = MagicMock()
env.id = "env-1"
mock_config.get_environments.return_value = [env]
client = MagicMock()
client.get_dashboard.side_effect = Exception("API error")
with patch("src.api.routes.git._helpers.SupersetClient", return_value=client):
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
result = await _resolve_repo_key_from_ref("123", 42, mock_config, "env-1")
assert result == "dashboard-42"
@pytest.mark.asyncio
async def test_empty_ref_uses_fallback(self):
from src.api.routes.git._helpers import _resolve_repo_key_from_ref
result = await _resolve_repo_key_from_ref("", 42, MagicMock())
assert result == "dashboard-42"
# ── _sanitize_optional_identity_value ──
class TestSanitizeOptionalIdentityValue:
def test_none(self):
from src.api.routes.git._helpers import _sanitize_optional_identity_value
assert _sanitize_optional_identity_value(None) is None
def test_empty(self):
from src.api.routes.git._helpers import _sanitize_optional_identity_value
assert _sanitize_optional_identity_value("") is None
def test_whitespace(self):
from src.api.routes.git._helpers import _sanitize_optional_identity_value
assert _sanitize_optional_identity_value(" ") is None
def test_valid(self):
from src.api.routes.git._helpers import _sanitize_optional_identity_value
assert _sanitize_optional_identity_value(" alice ") == "alice"
# ── _resolve_current_user_git_identity ──
class TestResolveCurrentUserGitIdentity:
def test_db_is_none(self):
from src.api.routes.git._helpers import _resolve_current_user_git_identity
assert _resolve_current_user_git_identity(None, MagicMock()) is None
def test_no_user_id(self):
from src.api.routes.git._helpers import _resolve_current_user_git_identity
user = MagicMock()
user.id = None
assert _resolve_current_user_git_identity(MagicMock(), user) is None
def test_preference_found(self):
mock_db = MagicMock()
pref = MagicMock()
pref.git_username = "alice"
pref.git_email = "alice@example.com"
mock_db.query.return_value.filter.return_value.first.return_value = pref
user = MagicMock()
user.id = "user-1"
from src.api.routes.git._helpers import _resolve_current_user_git_identity
result = _resolve_current_user_git_identity(mock_db, user)
assert result == ("alice", "alice@example.com")
def test_preference_not_found(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
user = MagicMock()
user.id = "user-1"
from src.api.routes.git._helpers import _resolve_current_user_git_identity
assert _resolve_current_user_git_identity(mock_db, user) is None
def test_preference_missing_git_fields(self):
mock_db = MagicMock()
pref = MagicMock()
pref.git_username = None
pref.git_email = None
mock_db.query.return_value.filter.return_value.first.return_value = pref
user = MagicMock()
user.id = "user-1"
from src.api.routes.git._helpers import _resolve_current_user_git_identity
assert _resolve_current_user_git_identity(mock_db, user) is None
def test_query_exception(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.side_effect = Exception("DB error")
user = MagicMock()
user.id = "user-1"
from src.api.routes.git._helpers import _resolve_current_user_git_identity
assert _resolve_current_user_git_identity(mock_db, user) is None
def test_db_without_query_attr(self):
from src.api.routes.git._helpers import _resolve_current_user_git_identity
assert _resolve_current_user_git_identity(object(), MagicMock()) is None
# ── _apply_git_identity_from_profile ──
class TestApplyGitIdentityFromProfile:
@pytest.mark.asyncio
async def test_identity_resolved_and_applied(self):
mock_service = MagicMock()
mock_service.configure_identity = AsyncMock()
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
patch("src.api.routes.git._helpers._resolve_current_user_git_identity",
return_value=("alice", "alice@example.com")):
from src.api.routes.git._helpers import _apply_git_identity_from_profile
await _apply_git_identity_from_profile(42, MagicMock(), MagicMock())
mock_service.configure_identity.assert_awaited_once_with(42, "alice", "alice@example.com")
@pytest.mark.asyncio
async def test_identity_not_resolved(self):
with patch("src.api.routes.git._helpers._resolve_current_user_git_identity",
return_value=None):
from src.api.routes.git._helpers import _apply_git_identity_from_profile
# Should not raise
await _apply_git_identity_from_profile(42, MagicMock(), MagicMock())
@pytest.mark.asyncio
async def test_service_has_no_configure_identity(self):
mock_service = MagicMock(spec=[]) # no configure_identity attribute
with patch("src.api.routes.git._helpers.get_git_service", return_value=mock_service), \
patch("src.api.routes.git._helpers._resolve_current_user_git_identity",
return_value=("alice", "alice@example.com")):
from src.api.routes.git._helpers import _apply_git_identity_from_profile
# Should not raise
await _apply_git_identity_from_profile(42, MagicMock(), MagicMock())
# #endregion Test.Api.GitHelpers

View File

@@ -0,0 +1,163 @@
# #region Test.Api.Health [C:3] [TYPE Module] [SEMANTICS test,health,api]
# @BRIEF Unit tests for health API routes — summary, delete report.
# @RELATION BINDS_TO -> [health_router]
# @TEST_EDGE: feature_disabled -> 404
# @TEST_EDGE: report_not_found -> 404
# @TEST_EDGE: environment_filter -> 200
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_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.health import router
from src.core.database import get_db
from src.dependencies import get_config_manager, get_current_user, 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_db: lambda: MagicMock(),
get_config_manager: 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)
class TestGetHealthSummary:
"""GET /api/health/summary"""
def test_summary_success(self):
"""Happy path: returns health summary."""
mock_config = MagicMock()
mock_config.get_config.return_value.settings.features.health_monitor = True
mock_service = AsyncMock()
mock_service.get_health_summary.return_value = {
"items": [],
"pass_count": 0,
"warn_count": 0,
"fail_count": 0,
"unknown_count": 0,
}
with patch("src.api.routes.health.HealthService", return_value=mock_service):
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/health/summary")
assert resp.status_code == 200
def test_summary_with_environment_filter(self):
"""Environment filter passed to service."""
mock_config = MagicMock()
mock_config.get_config.return_value.settings.features.health_monitor = True
mock_service = AsyncMock()
mock_service.get_health_summary.return_value = {
"items": [],
"pass_count": 0,
"warn_count": 0,
"fail_count": 0,
"unknown_count": 0,
}
with patch("src.api.routes.health.HealthService", return_value=mock_service):
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/health/summary?environment_id=prod")
assert resp.status_code == 200
mock_service.get_health_summary.assert_called_with(environment_id="prod")
def test_summary_feature_disabled(self):
"""Health monitor disabled returns 404."""
mock_config = MagicMock()
mock_config.get_config.return_value.settings.features.health_monitor = False
from src.dependencies import get_config_manager
client = _make_client({get_config_manager: lambda: mock_config})
resp = client.get("/api/health/summary")
assert resp.status_code == 404
assert "Health monitor feature is disabled" in resp.text
class TestDeleteHealthReport:
"""DELETE /api/health/summary/{record_id}"""
def test_delete_report_success(self):
"""Happy path: report deleted returns 204."""
mock_config = MagicMock()
mock_config.get_config.return_value.settings.features.health_monitor = True
mock_service = MagicMock()
mock_service.delete_validation_report.return_value = True
with patch("src.api.routes.health.HealthService", return_value=mock_service):
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.delete("/api/health/summary/rec-1")
assert resp.status_code == 204
mock_service.delete_validation_report.assert_called_once()
def test_delete_report_not_found(self):
"""Non-existent report returns 404."""
mock_config = MagicMock()
mock_config.get_config.return_value.settings.features.health_monitor = True
mock_service = MagicMock()
mock_service.delete_validation_report.return_value = False
with patch("src.api.routes.health.HealthService", return_value=mock_service):
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.delete("/api/health/summary/rec-missing")
assert resp.status_code == 404
assert "Health report not found" in resp.text
def test_delete_report_feature_disabled(self):
"""Health monitor disabled returns 404."""
mock_config = MagicMock()
mock_config.get_config.return_value.settings.features.health_monitor = False
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.delete("/api/health/summary/rec-1")
assert resp.status_code == 404
# #endregion Test.Api.Health

View File

@@ -0,0 +1,109 @@
# #region Test.Api.Plugins [C:2] [TYPE Module] [SEMANTICS test,plugins,api]
# @BRIEF Unit tests for plugins API route.
# @RELATION BINDS_TO -> [PluginsRouter]
# @TEST_EDGE: empty_list -> 200
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 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_client(overrides: dict | None = None) -> TestClient:
from src.api.routes.plugins import router
from src.dependencies import get_current_user, has_permission
from src.schemas.auth import User, RoleSchema
app = FastAPI()
# Plugins router has no prefix — must add it when including
app.include_router(router, prefix="/api/plugins")
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_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)
class TestListPlugins:
"""GET /api/plugins"""
def test_list_plugins_success(self):
"""Happy path: returns list of plugin configs."""
mock_loader = MagicMock()
mock_loader.get_all_plugin_configs.return_value = [
MagicMock(
name="superset-migration",
version="1.0.0",
enabled=True,
description="Migration plugin",
),
MagicMock(
name="superset-backup",
version="2.0.0",
enabled=False,
description="Backup plugin",
),
]
from src.dependencies import get_plugin_loader
client = _make_client({get_plugin_loader: lambda: mock_loader})
resp = client.get("/api/plugins")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 2
def test_list_plugins_empty(self):
"""Empty plugin list returns 200 with empty array."""
mock_loader = MagicMock()
mock_loader.get_all_plugin_configs.return_value = []
from src.dependencies import get_plugin_loader
client = _make_client({get_plugin_loader: lambda: mock_loader})
resp = client.get("/api/plugins")
assert resp.status_code == 200
assert resp.json() == []
def test_list_plugins_no_permission(self):
"""Without READ permission returns 403."""
from src.schemas.auth import User, RoleSchema
from src.dependencies import get_current_user
# User without Admin role and without permissions
app = FastAPI()
from src.api.routes.plugins import router
app.include_router(router, prefix="/api/plugins")
app.dependency_overrides[get_current_user] = lambda: User(
id="u1", username="regular", email="u@x.com",
auth_source="LOCAL",
created_at=__import__("datetime").datetime.now(),
roles=[],
)
client = TestClient(app)
resp = client.get("/api/plugins")
assert resp.status_code == 403
# #endregion Test.Api.Plugins