fix(agent): resolve ModuleNotFoundError for backend, add E2E test infra

- Dockerfile.agent: fix CMD (python -m src.agent.run), use backend/requirements.txt,
  minimal COPY (only src.agent + src.core.cot_logger), add GRACE contract
- docker-compose.yml: SERVICE_TOKEN_SECRET -> SERVICE_JWT (match code)
- docker-compose.enterprise-clean.yml: same env var fix
- docker/.env.agent.example: same env var fix
- build.sh: same env var fix
- chore: semantics-testing SKILL.md, backend tests, pyproject.toml
This commit is contained in:
2026-06-14 15:41:46 +03:00
parent 8f9856a646
commit 997329e2a5
33 changed files with 8976 additions and 721 deletions

View File

@@ -104,16 +104,28 @@ class TestDashboardMigration:
### Running tests
```bash
# All backend tests
# All backend tests (integration tests skipped by default)
cd backend && source .venv/bin/activate && python -m pytest -v
# Specific test file
python -m pytest tests/test_migration.py -v
# Include integration tests (PostgreSQL/Superset Testcontainers)
python -m pytest --run-integration
# Run only integration tests
python -m pytest tests/integration/ --run-integration
# With coverage
python -m pytest --cov=src --cov-report=term-missing
```
**Integration tests** (`tests/integration/`) use Testcontainers (PostgreSQL 16, Superset 4.1.2)
and require Docker. They are **skipped by default** — pass `--run-integration` to enable.
The `--run-integration` flag is registered in `backend/tests/conftest.py` via `pytest_addoption`;
skip logic lives in `backend/tests/integration/conftest.py` via `pytest_collection_modifyitems`.
See also: `backend/pyproject.toml` `[tool.pytest.ini_options] markers` for the registered marker.
## VII. SVELTE / VITEST CONVENTIONS
### Test file structure

View File

@@ -17,3 +17,6 @@ include = ["src*"]
[tool.pytest.ini_options]
pythonpath = ["."]
asyncio_mode = "auto"
markers = [
"integration: Integration tests requiring external services (Docker, Testcontainers, Superset). Use --run-integration to enable.",
]

View File

@@ -490,7 +490,13 @@ async def get_consolidated_settings(
response_payload = ConsolidatedSettingsResponse(
environments=[env.model_dump() for env in config.environments],
connections=[{**c.model_dump(), "password": "********"} for c in config.settings.connections],
connections=[
{
**(c.model_dump() if not isinstance(c, dict) else c),
"password": "********",
}
for c in config.settings.connections
],
llm=normalized_llm,
llm_providers=llm_providers_list,
logging=config.settings.logging.model_dump(),
@@ -534,13 +540,18 @@ async def update_consolidated_settings(
# Update connections if provided
if "connections" in settings_patch:
from ...core.config_models import DatabaseConnection as DBConnModel
cs = ConnectionService(config_manager)
# Build lookup of existing connections by id to preserve encrypted passwords
existing_by_id = {
c.id: c.password
for c in current_settings.connections
}
processed = []
# Guard: some items may still be dicts from a previous corruption — convert on the fly
existing_by_id: dict[str, str] = {}
for c in current_settings.connections:
if isinstance(c, dict):
existing_by_id[c.get("id", "")] = c.get("password", "")
else:
existing_by_id[c.id] = c.password
processed: list[DBConnModel] = []
for conn_dict in settings_patch["connections"]:
conn_id = conn_dict.get("id", "")
raw_pwd = conn_dict.get("password", "")
@@ -549,7 +560,8 @@ async def update_consolidated_settings(
conn_dict["password"] = existing_by_id[conn_id]
elif raw_pwd and raw_pwd != "********":
conn_dict["password"] = cs._encrypt_password(raw_pwd)
processed.append(conn_dict)
# Convert dict to DatabaseConnection model to preserve typed interface
processed.append(DBConnModel(**conn_dict))
current_settings.connections = processed
# Update LLM if provided

View File

@@ -0,0 +1,386 @@
#region TestSettingsConsolidated [C:3] [TYPE Module] [SEMANTICS test,settings,connections]
# @BRIEF Tests for GET/PATCH /api/settings/consolidated — connections typed-model enforcement.
# @RELATION BINDS_TO -> [get_consolidated_settings, update_consolidated_settings]
# @RELATION DEPENDS_ON -> [EXT:FastAPI:TestClient]
# @TEST_CONTRACT [GlobalSettings.connections] -> [ConsolidatedSettingsResponse | 200]
# @TEST_EDGE: dict_in_connections -> GET survives list[dict] in connections (no .model_dump crash)
# @TEST_EDGE: model_in_connections -> GET works with typed DatabaseConnection models
# @TEST_EDGE: empty_connections -> GET returns empty list
# @TEST_EDGE: patch_saves_as_model -> PATCH stores DatabaseConnection instances, not raw dicts
# @TEST_EDGE: patch_masked_password -> PATCH preserves existing password on "********"
# @TEST_EDGE: patch_new_password -> PATCH encrypts new passwords via ConnectionService
# @TEST_EDGE: unauthorized -> 403 when user lacks admin role
# @TEST_INVARIANT connections_typed -> VERIFIED_BY: patch_saves_as_model, patch_masked_password
# @INVARIANT current_settings.connections must always be list[DatabaseConnection], never list[dict]
# Set required env vars before ANY app imports — crash-early guard for AuthConfig()
import os
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:test_auth_settings_consolidated")
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:test_main_settings_consolidated")
os.environ.setdefault("DEV_MODE", "true")
import pytest
from unittest.mock import MagicMock
from fastapi.testclient import TestClient
from src.app import app
from src.core.config_models import (
DatabaseConnection,
FeaturesConfig,
GlobalSettings,
LoggingConfig,
)
from src.dependencies import (
get_config_manager,
get_current_user,
)
# ── Shared mocks ───────────────────────────────────────────────────────
mock_user = MagicMock()
mock_user.username = "testuser"
mock_user.roles = []
admin_role = MagicMock()
admin_role.name = "Admin"
admin_role.permissions = []
mock_user.roles.append(admin_role)
non_admin_role = MagicMock()
non_admin_role.name = "User"
non_admin_role.permissions = []
non_admin_role.is_admin = False
def _make_conn_dict(**overrides) -> dict:
"""Build a raw connection dict as the frontend would send it."""
return {
"id": overrides.get("id", "conn-1"),
"name": overrides.get("name", "Test DB"),
"host": overrides.get("host", "localhost"),
"port": overrides.get("port", 5432),
"database": overrides.get("database", "testdb"),
"username": overrides.get("username", "admin"),
"password": overrides.get("password", "secret"),
"dialect": overrides.get("dialect", "postgresql"),
"extra_params": overrides.get("extra_params", {}),
"pool_size": overrides.get("pool_size", 5),
}
def _make_conn_model(**overrides) -> DatabaseConnection:
"""Build a typed DatabaseConnection model."""
return DatabaseConnection(**_make_conn_dict(**overrides))
# ── Fixtures ───────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def mock_deps():
"""Override FastAPI dependencies for consolidated-settings routes.
get_current_user returns an Admin user by default — permission checks
pass through the real has_permission function.
Sets up a real GlobalSettings so typed-field assignment is testable.
"""
mock_user.roles = [admin_role]
# Real GlobalSettings with known state
real_settings = GlobalSettings()
real_settings.features = FeaturesConfig(translate=True, migration=False)
real_settings.logging = LoggingConfig()
# AppConfig mock — .settings is a real GlobalSettings;
# .environments is empty to avoid MagicMock noise in responses
app_config = MagicMock()
app_config.settings = real_settings
app_config.environments = []
mock_config_manager = MagicMock()
mock_config_manager.get_config.return_value = app_config
app.dependency_overrides[get_config_manager] = lambda: mock_config_manager
app.dependency_overrides[get_current_user] = lambda: mock_user
yield {
"config": mock_config_manager,
"settings": real_settings,
}
app.dependency_overrides.clear()
client = TestClient(app)
# ── GET /api/settings/consolidated ─────────────────────────────────────
#region test_get_consolidated_with_model_connections [C:2] [TYPE Function]
# @BRIEF GET returns 200 when connections contains DatabaseConnection models.
# @TEST_INVARIANT connections_typed -> VERIFIED_BY: test_get_consolidated_with_model_connections
def test_get_consolidated_with_model_connections(mock_deps):
"""DatabaseConnection models in settings.connections → 200, all passwords masked."""
settings: GlobalSettings = mock_deps["settings"]
settings.connections = [
_make_conn_model(id="c1", name="Alpha", password="real-pwd-1"),
_make_conn_model(id="c2", name="Beta", password="real-pwd-2"),
]
response = client.get("/api/settings/consolidated")
assert response.status_code == 200
data = response.json()
assert len(data["connections"]) == 2
for entry in data["connections"]:
assert entry["password"] == "********", "Passwords must be masked in response"
assert data["connections"][0]["name"] == "Alpha"
assert data["connections"][1]["name"] == "Beta"
assert data["features"]["translate"] is True
assert data["features"]["migration"] is False
#endregion test_get_consolidated_with_model_connections
#region test_get_consolidated_with_dict_connections [C:2] [TYPE Function]
# @BRIEF GET handles defensive case where connections contains raw dicts from previous corruption.
# @TEST_EDGE: dict_in_connections -> GET does not crash
def test_get_consolidated_with_dict_connections(mock_deps):
"""Raw dicts in settings.connections → 200 (no .model_dump crash)."""
settings: GlobalSettings = mock_deps["settings"]
settings.connections = [ # ← dicts, not DatabaseConnection models
{"id": "c1", "name": "Alpha", "password": "p1", "host": "h1", "port": 5432, "database": "d1", "username": "u1", "dialect": "postgresql"},
{"id": "c2", "name": "Beta", "password": "p2", "host": "h2", "port": 5432, "database": "d2", "username": "u2", "dialect": "postgresql"},
]
response = client.get("/api/settings/consolidated")
assert response.status_code == 200
data = response.json()
assert len(data["connections"]) == 2
for entry in data["connections"]:
assert entry["password"] == "********"
assert data["connections"][0]["name"] == "Alpha"
#endregion test_get_consolidated_with_dict_connections
#region test_get_consolidated_empty_connections [C:2] [TYPE Function]
# @BRIEF GET returns empty list when no connections configured.
# @TEST_EDGE: empty_connections -> GET returns []
def test_get_consolidated_empty_connections(mock_deps):
"""Empty connections list → 200, connections=[], nothing crashes."""
settings: GlobalSettings = mock_deps["settings"]
settings.connections = []
response = client.get("/api/settings/consolidated")
assert response.status_code == 200
data = response.json()
assert data["connections"] == []
#endregion test_get_consolidated_empty_connections
#region test_get_consolidated_unauthorized [C:2] [TYPE Function]
# @BRIEF GET returns 403 when user lacks admin:settings:READ permission.
# @TEST_EDGE: unauthorized -> 403
def test_get_consolidated_unauthorized():
"""Non-admin user → 403."""
mock_user.roles = [non_admin_role]
response = client.get("/api/settings/consolidated")
assert response.status_code == 403
#endregion test_get_consolidated_unauthorized
# ── PATCH /api/settings/consolidated ───────────────────────────────────
#region test_patch_connections_saves_as_model [C:2] [TYPE Function]
# @BRIEF PATCH with connections dicts stores them as DatabaseConnection models.
# @TEST_INVARIANT connections_typed -> VERIFIED_BY: test_patch_connections_saves_as_model
def test_patch_connections_saves_as_model(mock_deps):
"""After PATCH, current_settings.connections contains DatabaseConnection instances, not dicts."""
mock_config = mock_deps["config"]
payload = {
"connections": [
_make_conn_dict(id="c1", name="Prod DB", password="p@ss"),
]
}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 200
# Verify via update_global_settings call — connections must be typed
mock_config.update_global_settings.assert_called_once()
args, _ = mock_config.update_global_settings.call_args
updated_settings: GlobalSettings = args[0]
assert len(updated_settings.connections) == 1
conn = updated_settings.connections[0]
assert isinstance(conn, DatabaseConnection), (
f"Expected DatabaseConnection, got {type(conn).__name__}"
)
assert conn.id == "c1"
assert conn.name == "Prod DB"
assert conn.host == "localhost"
assert conn.dialect == "postgresql"
#endregion test_patch_connections_saves_as_model
#region test_patch_connections_masked_password [C:2] [TYPE Function]
# @BRIEF PATCH with "********" password preserves the existing encrypted password.
# @TEST_EDGE: patch_masked_password -> existing password not overwritten
def test_patch_connections_masked_password(mock_deps):
"""Masked password "********" → keeps encrypted password from existing config."""
settings: GlobalSettings = mock_deps["settings"]
settings.connections = [
_make_conn_model(id="c-exist", name="Existing", password="encrypted-vault-value"),
]
mock_config = mock_deps["config"]
payload = {
"connections": [
_make_conn_dict(id="c-exist", name="Existing", password="********"),
]
}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 200
mock_config.update_global_settings.assert_called_once()
args, _ = mock_config.update_global_settings.call_args
updated_settings: GlobalSettings = args[0]
conn = updated_settings.connections[0]
assert conn.password == "encrypted-vault-value", (
"Must preserve existing password when masked password sent"
)
#endregion test_patch_connections_masked_password
#region test_patch_connections_new_password_encrypted [C:2] [TYPE Function]
# @BRIEF PATCH with a new plaintext password encrypts it via ConnectionService.
# @TEST_EDGE: patch_new_password -> encrypts via ConnectionService._encrypt_password
def test_patch_connections_new_password_encrypted(mock_deps):
"""New plaintext password → ConnectionService._encrypt_password called."""
settings: GlobalSettings = mock_deps["settings"]
# Existing connection with an encrypted password
settings.connections = [
_make_conn_model(id="c-old", name="Old Conn", password="stale-encrypted"),
]
mock_config = mock_deps["config"]
payload = {
"connections": [
_make_conn_dict(id="c-old", name="Old Conn", password="new-plain-password"),
]
}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 200
# Password is NOT "********" and NOT "stale-encrypted" — it goes through
# ConnectionService._encrypt_password which (without real EncryptionManager)
# returns the password as-is or encrypts it. At minimum we verify it changed.
mock_config.update_global_settings.assert_called_once()
args, _ = mock_config.update_global_settings.call_args
updated_settings: GlobalSettings = args[0]
conn = updated_settings.connections[0]
assert conn.password != "********", "New password must not be masked"
assert conn.password != "stale-encrypted", "New password must replace old encrypted value"
assert conn.name == "Old Conn"
#endregion test_patch_connections_new_password_encrypted
#region test_patch_connections_multiple_at_once [C:2] [TYPE Function]
# @BRIEF PATCH processes multiple connections in one request.
def test_patch_connections_multiple_at_once(mock_deps):
"""Three connections in one PATCH → all stored as DatabaseConnection models."""
mock_config = mock_deps["config"]
payload = {
"connections": [
_make_conn_dict(id="c1", name="Alpha", password="p1"),
_make_conn_dict(id="c2", name="Beta", password="p2"),
_make_conn_dict(id="c3", name="Gamma", password="p3"),
]
}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 200
mock_config.update_global_settings.assert_called_once()
args, _ = mock_config.update_global_settings.call_args
updated_settings: GlobalSettings = args[0]
assert len(updated_settings.connections) == 3
for conn in updated_settings.connections:
assert isinstance(conn, DatabaseConnection)
assert [c.name for c in updated_settings.connections] == ["Alpha", "Beta", "Gamma"]
#endregion test_patch_connections_multiple_at_once
#region test_patch_connections_empty_list [C:2] [TYPE Function]
# @BRIEF PATCH with empty connections list clears the list.
def test_patch_connections_empty_list(mock_deps):
"""Empty connections list in PATCH → connections cleared."""
settings: GlobalSettings = mock_deps["settings"]
settings.connections = [
_make_conn_model(id="c1", name="To Delete", password="secret"),
]
mock_config = mock_deps["config"]
payload = {"connections": []}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 200
mock_config.update_global_settings.assert_called_once()
args, _ = mock_config.update_global_settings.call_args
updated_settings: GlobalSettings = args[0]
assert updated_settings.connections == []
#endregion test_patch_connections_empty_list
#region test_patch_settings_without_connections [C:2] [TYPE Function]
# @BRIEF PATCH without connections only updates other settings (features, timezone).
def test_patch_settings_without_connections(mock_deps):
"""No 'connections' key in payload → connections left untouched."""
settings: GlobalSettings = mock_deps["settings"]
settings.connections = [
_make_conn_model(id="c1", name="Existing Conn", password="secret"),
]
mock_config = mock_deps["config"]
payload = {
"features": {"translate": False, "migration": True},
"app_timezone": "Asia/Tokyo",
}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 200
mock_config.update_global_settings.assert_called_once()
args, _ = mock_config.update_global_settings.call_args
updated_settings: GlobalSettings = args[0]
# Connections preserved
assert len(updated_settings.connections) == 1
assert updated_settings.connections[0].name == "Existing Conn"
# Features updated
assert updated_settings.features.translate is False
assert updated_settings.features.migration is True
# Timezone updated
assert updated_settings.app_timezone == "Asia/Tokyo"
#endregion test_patch_settings_without_connections
#region test_patch_unauthorized [C:2] [TYPE Function]
# @BRIEF PATCH returns 403 when user lacks admin:settings:WRITE permission.
# @TEST_EDGE: unauthorized -> 403
def test_patch_unauthorized():
"""Non-admin user → 403."""
mock_user.roles = [non_admin_role]
payload = {"features": {"translate": False}}
response = client.patch("/api/settings/consolidated", json=payload)
assert response.status_code == 403
#endregion test_patch_unauthorized
#endregion TestSettingsConsolidated

View File

@@ -64,6 +64,17 @@ def make_fk_engine():
return eng
# ── CLI options ──
def pytest_addoption(parser):
parser.addoption(
"--run-integration",
action="store_true",
default=False,
help="Run integration tests (require Docker, Testcontainers, Superset container)",
)
# ── Test session lifecycle ──
def pytest_configure(config):
@@ -71,6 +82,10 @@ def pytest_configure(config):
f"\n[conftest] SQLite (global: {_TEST_DB_PATH}) + FK enforcement",
file=sys.stderr,
)
print(
" Integration tests skipped by default. Use --run-integration to enable.\n",
file=sys.stderr,
)
def pytest_unconfigure(config):

View File

@@ -0,0 +1,341 @@
# #region Test.SupersetClient.Base [C:3] [TYPE Module] [SEMANTICS test,superset,client,base]
# @BRIEF Unit tests for SupersetClientBase — init, auth, pagination, import/export helpers.
# @RELATION BINDS_TO -> [SupersetClientBase]
# @TEST_EDGE: missing_file -> _do_import raises FileNotFoundError
# @TEST_EDGE: invalid_zip -> _validate_import_file raises SupersetAPIError
# @TEST_EDGE: non_zip_export -> _validate_export_response raises SupersetAPIError
# @TEST_EDGE: empty_export -> _validate_export_response raises SupersetAPIError
# @TEST_EDGE: unknown_resource_type -> get_all_resources returns empty list
import io
import zipfile
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from src.core.config_models import Environment
from src.core.superset_client._base import SupersetClientBase, DEFAULT_PAGE_SIZE
from src.core.utils.network import SupersetAPIError
def _make_env(**overrides) -> Environment:
defaults = dict(id="env1", name="TestEnv", url="http://superset.test", username="admin", password="admin")
defaults.update(overrides)
return Environment(**defaults)
def _make_client(env=None, mock_client=None) -> SupersetClientBase:
env = env or _make_env()
mc = mock_client or MagicMock()
mc.authenticate = AsyncMock(return_value={"access_token": "tok", "csrf_token": "csrf"})
mc.get_headers = AsyncMock(return_value={"Authorization": "Bearer tok"})
mc.fetch_paginated_count = AsyncMock(return_value=42)
mc.fetch_paginated_data = AsyncMock(return_value=[{"id": 1}, {"id": 2}])
mc.upload_file = AsyncMock(return_value={"status": "ok"})
mc.aclose = AsyncMock()
return SupersetClientBase(env, client=mc)
# #region test_init_stores_env_and_creates_client [C:2] [TYPE Function]
# @BRIEF __init__ stores env, creates AsyncAPIClient when none provided.
def test_init_stores_env_and_creates_client():
env = _make_env()
with patch("src.core.superset_client._base.AsyncAPIClient") as MockCls:
mock_inst = MagicMock()
MockCls.return_value = mock_inst
obj = SupersetClientBase(env)
assert obj.env is env
assert obj.client is mock_inst
assert obj.network is mock_inst
assert obj.delete_before_reimport is False
MockCls.assert_called_once()
# #endregion test_init_stores_env_and_creates_client
# #region test_init_uses_provided_client [C:2] [TYPE Function]
# @BRIEF __init__ uses injected client instead of creating a new one.
def test_init_uses_provided_client():
env = _make_env()
injected = MagicMock()
obj = SupersetClientBase(env, client=injected)
assert obj.client is injected
assert obj.network is injected
# #endregion test_init_uses_provided_client
# #region test_authenticate_delegates_to_client [C:2] [TYPE Function]
# @BRIEF authenticate() delegates to client.authenticate() and returns tokens.
@pytest.mark.asyncio
async def test_authenticate_delegates_to_client():
obj = _make_client()
tokens = await obj.authenticate()
assert tokens == {"access_token": "tok", "csrf_token": "csrf"}
obj.client.authenticate.assert_awaited_once()
# #endregion test_authenticate_delegates_to_client
# #region test_get_headers_delegates_to_client [C:2] [TYPE Function]
# @BRIEF get_headers() delegates to client.get_headers().
@pytest.mark.asyncio
async def test_get_headers_delegates_to_client():
obj = _make_client()
headers = await obj.get_headers()
assert headers == {"Authorization": "Bearer tok"}
obj.client.get_headers.assert_awaited_once()
# #endregion test_get_headers_delegates_to_client
# #region test_validate_query_params_defaults [C:2] [TYPE Function]
# @BRIEF _validate_query_params applies default page=0 and page_size=100.
def test_validate_query_params_defaults():
obj = _make_client()
result = obj._validate_query_params(None)
assert result == {"page": 0, "page_size": DEFAULT_PAGE_SIZE}
# #endregion test_validate_query_params_defaults
# #region test_validate_query_params_merge [C:2] [TYPE Function]
# @BRIEF _validate_query_params merges caller overrides over defaults.
def test_validate_query_params_merge():
obj = _make_client()
result = obj._validate_query_params({"page": 3, "columns": ["id"]})
assert result["page"] == 3
assert result["page_size"] == DEFAULT_PAGE_SIZE
assert result["columns"] == ["id"]
# #endregion test_validate_query_params_merge
# #region test_fetch_total_object_count [C:2] [TYPE Function]
# @BRIEF _fetch_total_object_count delegates to client.fetch_paginated_count.
@pytest.mark.asyncio
async def test_fetch_total_object_count():
obj = _make_client()
count = await obj._fetch_total_object_count("/dashboard/")
assert count == 42
obj.client.fetch_paginated_count.assert_awaited_once_with(
endpoint="/dashboard/",
query_params={"page": 0, "page_size": 1},
count_field="count",
)
# #endregion test_fetch_total_object_count
# #region test_fetch_all_pages [C:2] [TYPE Function]
# @BRIEF _fetch_all_pages delegates to client.fetch_paginated_data.
@pytest.mark.asyncio
async def test_fetch_all_pages():
obj = _make_client()
opts = {"base_query": {"page": 0}, "results_field": "result"}
data = await obj._fetch_all_pages("/chart/", opts)
assert data == [{"id": 1}, {"id": 2}]
obj.client.fetch_paginated_data.assert_awaited_once_with(
endpoint="/chart/", pagination_options=opts
)
# #endregion test_fetch_all_pages
# #region test_do_import_success [C:2] [TYPE Function]
# @BRIEF _do_import uploads an existing file via client.upload_file.
@pytest.mark.asyncio
async def test_do_import_success(tmp_path):
obj = _make_client()
fake_file = tmp_path / "export.zip"
fake_file.write_bytes(b"PK\x03\x04fake")
result = await obj._do_import(str(fake_file))
assert result == {"status": "ok"}
obj.client.upload_file.assert_awaited_once()
# #endregion test_do_import_success
# #region test_do_import_file_not_found [C:2] [TYPE Function]
# @BRIEF _do_import raises FileNotFoundError when file does not exist.
@pytest.mark.asyncio
async def test_do_import_file_not_found():
obj = _make_client()
with pytest.raises(FileNotFoundError, match="does_not_exist.zip"):
await obj._do_import("/tmp/does_not_exist.zip")
# #endregion test_do_import_file_not_found
# #region test_validate_export_response_success [C:2] [TYPE Function]
# @BRIEF _validate_export_response passes for valid ZIP content-type with body.
def test_validate_export_response_success():
obj = _make_client()
resp = MagicMock(spec=httpx.Response)
resp.headers = {"Content-Type": "application/zip"}
resp.content = b"PK\x03\x04data"
obj._validate_export_response(resp, 1) # no exception
# #endregion test_validate_export_response_success
# #region test_validate_export_response_not_zip [C:2] [TYPE Function]
# @BRIEF _validate_export_response raises SupersetAPIError for non-ZIP content-type.
def test_validate_export_response_not_zip():
obj = _make_client()
resp = MagicMock(spec=httpx.Response)
resp.headers = {"Content-Type": "application/json"}
resp.content = b'{"error": true}'
with pytest.raises(SupersetAPIError, match="не ZIP"):
obj._validate_export_response(resp, 1)
# #endregion test_validate_export_response_not_zip
# #region test_validate_export_response_empty [C:2] [TYPE Function]
# @BRIEF _validate_export_response raises SupersetAPIError for empty body.
def test_validate_export_response_empty():
obj = _make_client()
resp = MagicMock(spec=httpx.Response)
resp.headers = {"Content-Type": "application/zip"}
resp.content = b""
with pytest.raises(SupersetAPIError, match="пустые"):
obj._validate_export_response(resp, 1)
# #endregion test_validate_export_response_empty
# #region test_resolve_export_filename_from_header [C:2] [TYPE Function]
# @BRIEF _resolve_export_filename extracts filename from Content-Disposition header.
def test_resolve_export_filename_from_header():
obj = _make_client()
resp = MagicMock(spec=httpx.Response)
resp.headers = {"Content-Disposition": 'attachment; filename="my_dash.zip"'}
fname = obj._resolve_export_filename(resp, 99)
assert fname == "my_dash.zip"
# #endregion test_resolve_export_filename_from_header
# #region test_resolve_export_filename_fallback [C:2] [TYPE Function]
# @BRIEF _resolve_export_filename generates timestamped name when header is absent.
def test_resolve_export_filename_fallback():
obj = _make_client()
resp = MagicMock(spec=httpx.Response)
resp.headers = {}
fname = obj._resolve_export_filename(resp, 42)
assert fname.startswith("dashboard_export_42_")
assert fname.endswith(".zip")
# #endregion test_resolve_export_filename_fallback
# #region test_validate_import_file_valid [C:2] [TYPE Function]
# @BRIEF _validate_import_file passes for a valid ZIP containing metadata.yaml.
def test_validate_import_file_valid(tmp_path):
obj = _make_client()
zip_path = tmp_path / "valid.zip"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("dashboard_1/metadata.yaml", "title: test")
zip_path.write_bytes(buf.getvalue())
obj._validate_import_file(str(zip_path)) # no exception
# #endregion test_validate_import_file_valid
# #region test_validate_import_file_not_zip [C:2] [TYPE Function]
# @BRIEF _validate_import_file raises SupersetAPIError for non-ZIP file.
def test_validate_import_file_not_zip(tmp_path):
obj = _make_client()
bad = tmp_path / "bad.zip"
bad.write_bytes(b"not a zip file")
with pytest.raises(SupersetAPIError, match="не является ZIP"):
obj._validate_import_file(str(bad))
# #endregion test_validate_import_file_not_zip
# #region test_validate_import_file_missing_metadata [C:2] [TYPE Function]
# @BRIEF _validate_import_file raises SupersetAPIError when metadata.yaml is absent.
def test_validate_import_file_missing_metadata(tmp_path):
obj = _make_client()
zip_path = tmp_path / "no_meta.zip"
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("dashboard_1/charts.yaml", "data: x")
zip_path.write_bytes(buf.getvalue())
with pytest.raises(SupersetAPIError, match="metadata.yaml"):
obj._validate_import_file(str(zip_path))
# #endregion test_validate_import_file_missing_metadata
# #region test_validate_import_file_not_found [C:2] [TYPE Function]
# @BRIEF _validate_import_file raises FileNotFoundError for missing path.
def test_validate_import_file_not_found():
obj = _make_client()
with pytest.raises(FileNotFoundError):
obj._validate_import_file("/tmp/nonexistent_file.zip")
# #endregion test_validate_import_file_not_found
# #region test_resolve_target_id_by_direct_id [C:2] [TYPE Function]
# @BRIEF _resolve_target_id_for_delete returns dash_id directly when provided.
@pytest.mark.asyncio
async def test_resolve_target_id_by_direct_id():
obj = _make_client()
result = await obj._resolve_target_id_for_delete(dash_id=10, dash_slug=None)
assert result == 10
# #endregion test_resolve_target_id_by_direct_id
# #region test_resolve_target_id_by_slug [C:2] [TYPE Function]
# @BRIEF _resolve_target_id_for_delete resolves slug via get_dashboards.
@pytest.mark.asyncio
async def test_resolve_target_id_by_slug():
obj = _make_client()
obj.get_dashboards = AsyncMock(return_value=(1, [{"id": 77}]))
result = await obj._resolve_target_id_for_delete(dash_id=None, dash_slug="my-slug")
assert result == 77
# #endregion test_resolve_target_id_by_slug
# #region test_resolve_target_id_slug_not_found [C:2] [TYPE Function]
# @BRIEF _resolve_target_id_for_delete returns None when slug resolves to nothing.
@pytest.mark.asyncio
async def test_resolve_target_id_slug_not_found():
obj = _make_client()
obj.get_dashboards = AsyncMock(return_value=(0, []))
result = await obj._resolve_target_id_for_delete(dash_id=None, dash_slug="nope")
assert result is None
# #endregion test_resolve_target_id_slug_not_found
# #region test_resolve_target_id_slug_exception [C:2] [TYPE Function]
# @BRIEF _resolve_target_id_for_delete returns None when slug lookup raises.
@pytest.mark.asyncio
async def test_resolve_target_id_slug_exception():
obj = _make_client()
obj.get_dashboards = AsyncMock(side_effect=RuntimeError("network down"))
result = await obj._resolve_target_id_for_delete(dash_id=None, dash_slug="oops")
assert result is None
# #endregion test_resolve_target_id_slug_exception
# #region test_resolve_target_id_both_none [C:2] [TYPE Function]
# @BRIEF _resolve_target_id_for_delete returns None when both args are None.
@pytest.mark.asyncio
async def test_resolve_target_id_both_none():
obj = _make_client()
result = await obj._resolve_target_id_for_delete(dash_id=None, dash_slug=None)
assert result is None
# #endregion test_resolve_target_id_both_none
# #region test_get_all_resources_chart [C:2] [TYPE Function]
# @BRIEF get_all_resources fetches chart resources with correct columns.
@pytest.mark.asyncio
async def test_get_all_resources_chart():
obj = _make_client()
data = await obj.get_all_resources("chart")
assert len(data) == 2
call_kwargs = obj.client.fetch_paginated_data.call_args
assert call_kwargs.kwargs["endpoint"] == "/chart/"
base_query = call_kwargs.kwargs["pagination_options"]["base_query"]
assert "id" in base_query["columns"]
assert "uuid" in base_query["columns"]
# #endregion test_get_all_resources_chart
# #region test_get_all_resources_unknown_type [C:2] [TYPE Function]
# @BRIEF get_all_resources returns empty list for unknown resource type.
@pytest.mark.asyncio
async def test_get_all_resources_unknown_type():
obj = _make_client()
data = await obj.get_all_resources("unknown_type")
assert data == []
# #endregion test_get_all_resources_unknown_type
# #region test_get_all_resources_with_since [C:2] [TYPE Function]
# @BRIEF get_all_resources adds timestamp filter when since_dttm is provided.
@pytest.mark.asyncio
async def test_get_all_resources_with_since():
obj = _make_client()
since = datetime(2025, 1, 1, tzinfo=timezone.utc)
await obj.get_all_resources("dashboard", since_dttm=since)
call_kwargs = obj.client.fetch_paginated_data.call_args
base_query = call_kwargs.kwargs["pagination_options"]["base_query"]
assert "filters" in base_query
assert base_query["filters"][0]["col"] == "changed_on_dttm"
# #endregion test_get_all_resources_with_since
# #region test_aclose_delegates [C:2] [TYPE Function]
# @BRIEF aclose() delegates to client.aclose().
@pytest.mark.asyncio
async def test_aclose_delegates():
obj = _make_client()
await obj.aclose()
obj.client.aclose.assert_awaited_once()
# #endregion test_aclose_delegates
# #endregion Test.SupersetClient.Base

View File

@@ -0,0 +1,327 @@
# #region Test.SupersetClient.Charts [C:3] [TYPE Module] [SEMANTICS test,superset,chart,database]
# @BRIEF Unit tests for chart and database mixins — get, list, layout extraction, CRUD.
# @RELATION BINDS_TO -> [SupersetChartsMixin]
# @RELATION BINDS_TO -> [SupersetDatabasesMixin]
# @TEST_EDGE: empty_charts -> get_charts returns (0, [])
# @TEST_EDGE: unknown_chart_id_key -> _extract_chart_ids_from_layout skips unparseable values
# @TEST_EDGE: empty_databases -> get_databases returns (0, [])
# @TEST_EDGE: database_uuid_not_found -> get_database_by_uuid returns None
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.core.config_models import Environment
from src.core.superset_client import SupersetClient
def _make_env() -> Environment:
return Environment(id="env1", name="T", url="http://test.local", username="u", password="p")
def _make_client() -> SupersetClient:
env = _make_env()
client = SupersetClient(env)
mc = MagicMock()
mc.request = AsyncMock(return_value={})
mc.fetch_paginated_data = AsyncMock(return_value=[])
client.client = mc
client.network = mc
return client
# ── Charts: get_chart ───────────────────────────────────────────────────────
# #region test_get_chart_success [C:2] [TYPE Function]
# @BRIEF get_chart returns the response dict for a valid chart ID.
@pytest.mark.asyncio
async def test_get_chart_success():
client = _make_client()
expected = {"result": {"id": 7, "slice_name": "Revenue"}}
client.client.request = AsyncMock(return_value=expected)
result = await client.get_chart(7)
assert result == expected
client.client.request.assert_awaited_once_with(method="GET", endpoint="/chart/7")
# #endregion test_get_chart_success
# #region test_get_chart_not_found [C:2] [TYPE Function]
# @BRIEF get_chart propagates error when chart does not exist (404 from API).
@pytest.mark.asyncio
async def test_get_chart_not_found():
client = _make_client()
client.client.request = AsyncMock(side_effect=Exception("404 Not Found"))
with pytest.raises(Exception, match="404"):
await client.get_chart(9999)
# #endregion test_get_chart_not_found
# ── Charts: get_charts ──────────────────────────────────────────────────────
# #region test_get_charts_success [C:2] [TYPE Function]
# @BRIEF get_charts returns (count, data) from paginated fetch.
@pytest.mark.asyncio
async def test_get_charts_success():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[
{"id": 1, "slice_name": "A"}, {"id": 2, "slice_name": "B"},
])
count, data = await client.get_charts()
assert count == 2
assert data[0]["slice_name"] == "A"
# #endregion test_get_charts_success
# #region test_get_charts_empty [C:2] [TYPE Function]
# @BRIEF get_charts returns (0, []) when no charts exist.
@pytest.mark.asyncio
async def test_get_charts_empty():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[])
count, data = await client.get_charts()
assert count == 0
assert data == []
# #endregion test_get_charts_empty
# #region test_get_charts_custom_columns [C:2] [TYPE Function]
# @BRIEF get_charts passes custom columns through to pagination query.
@pytest.mark.asyncio
async def test_get_charts_custom_columns():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[{"id": 1}])
await client.get_charts(query={"columns": ["id", "slice_name", "viz_type"]})
call_kwargs = client.client.fetch_paginated_data.call_args
base_query = call_kwargs.kwargs["pagination_options"]["base_query"]
assert base_query["columns"] == ["id", "slice_name", "viz_type"]
# #endregion test_get_charts_custom_columns
# ── Charts: _extract_chart_ids_from_layout ──────────────────────────────────
# #region test_extract_chart_ids_chartId_key [C:2] [TYPE Function]
# @BRIEF _extract_chart_ids_from_layout finds IDs via 'chartId' key.
def test_extract_chart_ids_chartId_key():
client = _make_client()
layout = {"row1": {"chartId": 10}, "row2": {"chartId": 20}}
ids = client._extract_chart_ids_from_layout(layout)
assert ids == {10, 20}
# #endregion test_extract_chart_ids_chartId_key
# #region test_extract_chart_ids_chart_id_key [C:2] [TYPE Function]
# @BRIEF _extract_chart_ids_from_layout finds IDs via 'chart_id' key.
def test_extract_chart_ids_chart_id_key():
client = _make_client()
layout = {"a": {"chart_id": 5}}
ids = client._extract_chart_ids_from_layout(layout)
assert ids == {5}
# #endregion test_extract_chart_ids_chart_id_key
# #region test_extract_chart_ids_slice_id_key [C:2] [TYPE Function]
# @BRIEF _extract_chart_ids_from_layout finds IDs via 'slice_id' and 'sliceId' keys.
def test_extract_chart_ids_slice_id_key():
client = _make_client()
layout = {"x": {"slice_id": 3}, "y": {"sliceId": 4}}
ids = client._extract_chart_ids_from_layout(layout)
assert ids == {3, 4}
# #endregion test_extract_chart_ids_slice_id_key
# #region test_extract_chart_ids_CHART_dash_N_pattern [C:2] [TYPE Function]
# @BRIEF _extract_chart_ids_from_layout extracts numeric IDs from 'CHART-N' string ids.
def test_extract_chart_ids_CHART_dash_N_pattern():
client = _make_client()
layout = {"a": {"id": "CHART-42"}, "b": {"id": "CHART-7"}, "c": {"id": "OTHER-1"}}
ids = client._extract_chart_ids_from_layout(layout)
assert ids == {42, 7}
# #endregion test_extract_chart_ids_CHART_dash_N_pattern
# #region test_extract_chart_ids_nested [C:2] [TYPE Function]
# @BRIEF _extract_chart_ids_from_layout traverses deeply nested structures.
def test_extract_chart_ids_nested():
client = _make_client()
layout = {
"rows": [
{"cols": [{"chartId": 1}, {"chartId": 2}]},
{"cols": [{"chartId": 3}]},
]
}
ids = client._extract_chart_ids_from_layout(layout)
assert ids == {1, 2, 3}
# #endregion test_extract_chart_ids_nested
# #region test_extract_chart_ids_empty [C:2] [TYPE Function]
# @BRIEF _extract_chart_ids_from_layout returns empty set for empty payload.
def test_extract_chart_ids_empty():
client = _make_client()
assert client._extract_chart_ids_from_layout({}) == set()
assert client._extract_chart_ids_from_layout([]) == set()
# #endregion test_extract_chart_ids_empty
# #region test_extract_chart_ids_unparseable [C:2] [TYPE Function]
# @BRIEF _extract_chart_ids_from_layout skips unparseable chart ID values.
def test_extract_chart_ids_unparseable():
client = _make_client()
layout = {"a": {"chartId": "not_a_number"}, "b": {"chartId": 99}}
ids = client._extract_chart_ids_from_layout(layout)
assert ids == {99}
# #endregion test_extract_chart_ids_unparseable
# #region test_extract_chart_ids_non_dict_input [C:2] [TYPE Function]
# @BRIEF _extract_chart_ids_from_layout handles scalar input gracefully.
def test_extract_chart_ids_non_dict_input():
client = _make_client()
assert client._extract_chart_ids_from_layout("string") == set()
assert client._extract_chart_ids_from_layout(42) == set()
assert client._extract_chart_ids_from_layout(None) == set()
# #endregion test_extract_chart_ids_non_dict_input
# ── Databases: get_databases ────────────────────────────────────────────────
# #region test_get_databases_success [C:2] [TYPE Function]
# @BRIEF get_databases returns (count, data) from paginated fetch.
@pytest.mark.asyncio
async def test_get_databases_success():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[
{"id": 1, "database_name": "pg"}, {"id": 2, "database_name": "mysql"},
])
count, data = await client.get_databases()
assert count == 2
assert data[0]["database_name"] == "pg"
# #endregion test_get_databases_success
# #region test_get_databases_empty [C:2] [TYPE Function]
# @BRIEF get_databases returns (0, []) when no databases exist.
@pytest.mark.asyncio
async def test_get_databases_empty():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[])
count, data = await client.get_databases()
assert count == 0
assert data == []
# #endregion test_get_databases_empty
# ── Databases: get_database ─────────────────────────────────────────────────
# #region test_get_database_success [C:2] [TYPE Function]
# @BRIEF get_database returns response dict for a valid database ID.
@pytest.mark.asyncio
async def test_get_database_success():
client = _make_client()
expected = {"id": 3, "database_name": "pg_main", "backend": "postgresql"}
client.client.request = AsyncMock(return_value=expected)
result = await client.get_database(3)
assert result == expected
client.client.request.assert_awaited_once_with(method="GET", endpoint="/database/3")
# #endregion test_get_database_success
# #region test_get_database_not_found [C:2] [TYPE Function]
# @BRIEF get_database propagates error for non-existent database ID.
@pytest.mark.asyncio
async def test_get_database_not_found():
client = _make_client()
client.client.request = AsyncMock(side_effect=Exception("404 Not Found"))
with pytest.raises(Exception, match="404"):
await client.get_database(9999)
# #endregion test_get_database_not_found
# ── Databases: get_databases_summary ────────────────────────────────────────
# #region test_get_databases_summary [C:2] [TYPE Function]
# @BRIEF get_databases_summary renames 'backend' key to 'engine'.
@pytest.mark.asyncio
async def test_get_databases_summary():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[
{"id": 1, "uuid": "abc", "database_name": "pg", "backend": "postgresql"},
])
result = await client.get_databases_summary()
assert len(result) == 1
assert result[0]["engine"] == "postgresql"
assert "backend" not in result[0]
# #endregion test_get_databases_summary
# #region test_get_databases_summary_empty [C:2] [TYPE Function]
# @BRIEF get_databases_summary returns empty list when no databases.
@pytest.mark.asyncio
async def test_get_databases_summary_empty():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[])
result = await client.get_databases_summary()
assert result == []
# #endregion test_get_databases_summary_empty
# ── Databases: get_database_by_uuid ─────────────────────────────────────────
# #region test_get_database_by_uuid_found [C:2] [TYPE Function]
# @BRIEF get_database_by_uuid returns the first match.
@pytest.mark.asyncio
async def test_get_database_by_uuid_found():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[
{"id": 5, "uuid": "target-uuid", "database_name": "found"},
])
result = await client.get_database_by_uuid("target-uuid")
assert result["id"] == 5
# #endregion test_get_database_by_uuid_found
# #region test_get_database_by_uuid_not_found [C:2] [TYPE Function]
# @BRIEF get_database_by_uuid returns None when no match.
@pytest.mark.asyncio
async def test_get_database_by_uuid_not_found():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[])
result = await client.get_database_by_uuid("nonexistent-uuid")
assert result is None
# #endregion test_get_database_by_uuid_not_found
# ── Databases: create_database ──────────────────────────────────────────────
# #region test_create_database_success [C:2] [TYPE Function]
# @BRIEF create_database sends POST with correct payload and returns response.
@pytest.mark.asyncio
async def test_create_database_success():
client = _make_client()
client.client.request = AsyncMock(return_value={"id": 10, "result": "created"})
result = await client.create_database("mydb", "postgresql://localhost/mydb")
assert result["id"] == 10
call_kwargs = client.client.request.call_args
assert call_kwargs.kwargs["method"] == "POST"
assert call_kwargs.kwargs["endpoint"] == "/database/"
data = call_kwargs.kwargs["data"]
assert data["database_name"] == "mydb"
assert data["sqlalchemy_uri"] == "postgresql://localhost/mydb"
assert data["expose_in_sqllab"] is True
assert data["allow_dml"] is False
# #endregion test_create_database_success
# #region test_create_database_custom_flags [C:2] [TYPE Function]
# @BRIEF create_database passes custom expose_in_sqllab and allow_dml flags.
@pytest.mark.asyncio
async def test_create_database_custom_flags():
client = _make_client()
client.client.request = AsyncMock(return_value={"id": 11})
await client.create_database("db2", "sqlite:///test.db", expose_in_sqllab=False, allow_dml=True)
data = client.client.request.call_args.kwargs["data"]
assert data["expose_in_sqllab"] is False
assert data["allow_dml"] is True
# #endregion test_create_database_custom_flags
# ── Databases: delete_database ──────────────────────────────────────────────
# #region test_delete_database_success [C:2] [TYPE Function]
# @BRIEF delete_database sends DELETE request and returns response.
@pytest.mark.asyncio
async def test_delete_database_success():
client = _make_client()
client.client.request = AsyncMock(return_value={"result": "deleted"})
result = await client.delete_database(7)
assert result == {"result": "deleted"}
client.client.request.assert_awaited_once_with(method="DELETE", endpoint="/database/7")
# #endregion test_delete_database_success
# #region test_delete_database_not_found [C:2] [TYPE Function]
# @BRIEF delete_database propagates error for non-existent database.
@pytest.mark.asyncio
async def test_delete_database_not_found():
client = _make_client()
client.client.request = AsyncMock(side_effect=Exception("404"))
with pytest.raises(Exception, match="404"):
await client.delete_database(9999)
# #endregion test_delete_database_not_found
# #endregion Test.SupersetClient.Charts

View File

@@ -0,0 +1,395 @@
# #region Test.SupersetClient.DashboardsCrud [C:3] [TYPE Module] [SEMANTICS test,superset,dashboard,crud]
# @BRIEF Unit tests for dashboard CRUD mixin — detail, export, import, delete.
# @RELATION BINDS_TO -> [SupersetDashboardsCrudMixin]
# @TEST_EDGE: charts_fetch_fails -> get_dashboard_detail falls back to layout extraction
# @TEST_EDGE: import_fails_no_delete -> import_dashboard re-raises when delete_before_reimport is False
# @TEST_EDGE: import_fails_retry -> import_dashboard deletes then retries when delete_before_reimport is True
import io, json, zipfile
from unittest.mock import AsyncMock, MagicMock
import httpx, pytest
from src.core.config_models import Environment
from src.core.superset_client import SupersetClient
from src.core.utils.network import SupersetAPIError
def _make_client() -> SupersetClient:
env = Environment(id="env1", name="T", url="http://test.local", username="u", password="p")
client = SupersetClient(env)
mc = MagicMock()
mc.request = AsyncMock(return_value={})
mc.fetch_paginated_data = AsyncMock(return_value=[])
mc.upload_file = AsyncMock(return_value={"status": "ok"})
client.client = mc; client.network = mc
return client
def _detail_mock(client, charts_resp, datasets_resp):
async def mock_req(method, endpoint, **kw):
if "/charts" in endpoint: return charts_resp
if "/datasets" in endpoint: return datasets_resp
return {}
client.client.request = AsyncMock(side_effect=mock_req)
def _zip_with_metadata(tmp_path, name="import.zip"):
p = tmp_path / name
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("dash/metadata.yaml", "title: x")
p.write_bytes(buf.getvalue())
return p
# ── get_dashboard_detail ────────────────────────────────────────────────────
# #region test_detail_success [C:2] [TYPE Function]
# @BRIEF Happy path: dashboard with charts and datasets from API responses.
@pytest.mark.asyncio
async def test_detail_success():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {
"id": 10, "dashboard_title": "My Dash", "slug": "my-dash", "url": "/dash/10",
"description": "desc", "published": True, "changed_on_utc": "2025-01-01",
"position_json": "", "json_metadata": "",
}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c,
charts_resp={"result": [{"id": 100, "slice_name": "Chart A",
"form_data": '{"viz_type":"table","datasource":"42__table"}',
"datasource_id": None, "changed_on": "2025-01-02", "description": "A chart"}]},
datasets_resp={"result": [{"id": 42, "table_name": "orders", "schema": "public",
"database": {"database_name": "pg_main"}, "changed_on": "2025-01-03"}]})
r = await c.get_dashboard_detail(10)
assert r["id"] == 10 and r["title"] == "My Dash"
assert r["chart_count"] == 1 and r["charts"][0]["dataset_id"] == 42
assert r["dataset_count"] == 1 and r["datasets"][0]["table_name"] == "orders"
# #endregion test_detail_success
# #region test_detail_charts_fail_fallback [C:2] [TYPE Function]
# @BRIEF When charts endpoint fails, falls back to layout-based chart extraction.
@pytest.mark.asyncio
async def test_detail_charts_fail_fallback():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {
"id": 20, "dashboard_title": "FB", "position_json": json.dumps({"CHART-5": {"chartId": 5}, "CHART-9": {"chartId": 9}}),
"json_metadata": "", "changed_on_utc": "2025-01-01"}})
c.get_chart = AsyncMock(side_effect=[
{"result": {"id": 5, "slice_name": "C5", "viz_type": "bar", "datasource_id": 1, "changed_on": "t", "description": "d"}},
{"result": {"id": 9, "slice_name": "C9", "viz_type": "line", "datasource_id": 2, "changed_on": "t", "description": "d"}}])
c.get_dataset = AsyncMock(return_value={"result": {"id": 1, "table_name": "t1", "schema": None, "database": {"database_name": "db"}, "changed_on": "t"}})
c.client.request = AsyncMock(side_effect=RuntimeError("net err"))
r = await c.get_dashboard_detail(20)
assert {ch["id"] for ch in r["charts"]} == {5, 9}
# #endregion test_detail_charts_fail_fallback
# #region test_detail_empty [C:2] [TYPE Function]
# @BRIEF Dashboard with no charts/datasets returns empty lists.
@pytest.mark.asyncio
async def test_detail_empty():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 30, "dashboard_title": "E", "position_json": "", "json_metadata": ""}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
c.client.request = AsyncMock(return_value={"result": []})
r = await c.get_dashboard_detail(30)
assert r["charts"] == [] and r["datasets"] == [] and r["chart_count"] == 0
# #endregion test_detail_empty
# #region test_detail_backfill_datasets [C:2] [TYPE Function]
# @BRIEF Datasets referenced by charts but missing from datasets endpoint are backfilled.
@pytest.mark.asyncio
async def test_detail_backfill_datasets():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 40, "dashboard_title": "BF"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock(return_value={"result": {
"id": 99, "table_name": "backfilled", "schema": "s", "database": {"database_name": "db"}, "changed_on_utc": "t"}})
_detail_mock(c, charts_resp={"result": [{"id": 1, "slice_name": "C1", "form_data": {"datasource": {"id": 99}}, "datasource_id": 99}]},
datasets_resp={"result": []})
r = await c.get_dashboard_detail(40)
assert any(d["id"] == 99 for d in r["datasets"])
# #endregion test_detail_backfill_datasets
# ── extract_dataset_id edge cases ───────────────────────────────────────────
# #region test_detail_form_data_not_dict [C:2] [TYPE Function]
# @BRIEF Non-dict form_data yields dataset_id=None.
@pytest.mark.asyncio
async def test_detail_form_data_not_dict():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 50, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": [{"id": 10, "slice_name": "C", "form_data": None, "datasource_id": None}]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(50))["charts"][0]["dataset_id"] is None
# #endregion test_detail_form_data_not_dict
# #region test_detail_form_data_dict_datasource [C:2] [TYPE Function]
# @BRIEF Dict datasource extracts id correctly.
@pytest.mark.asyncio
async def test_detail_form_data_dict_datasource():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 51, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": [{"id": 11, "slice_name": "C", "form_data": {"datasource": {"id": 77}}, "datasource_id": None}]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(51))["charts"][0]["dataset_id"] == 77
# #endregion test_detail_form_data_dict_datasource
# #region test_detail_form_data_datasource_id_fallback [C:2] [TYPE Function]
# @BRIEF datasource_id field (no datasource key) extracts id.
@pytest.mark.asyncio
async def test_detail_form_data_datasource_id_fallback():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 52, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": [{"id": 12, "slice_name": "C", "form_data": {"datasource_id": 88}, "datasource_id": None}]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(52))["charts"][0]["dataset_id"] == 88
# #endregion test_detail_form_data_datasource_id_fallback
# #region test_detail_chart_obj_not_dict [C:2] [TYPE Function]
# @BRIEF Non-dict chart entries are skipped.
@pytest.mark.asyncio
async def test_detail_chart_obj_not_dict():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 53, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": ["not_a_dict", 42, None]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(53))["charts"] == []
# #endregion test_detail_chart_obj_not_dict
# #region test_detail_chart_id_none [C:2] [TYPE Function]
# @BRIEF Chart entries with no id field are skipped.
@pytest.mark.asyncio
async def test_detail_chart_id_none():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 54, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": [{"slice_name": "NoID"}]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(54))["charts"] == []
# #endregion test_detail_chart_id_none
# #region test_detail_form_data_bad_json [C:2] [TYPE Function]
# @BRIEF Invalid JSON form_data falls back to empty dict.
@pytest.mark.asyncio
async def test_detail_form_data_bad_json():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 55, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": [{"id": 15, "slice_name": "C", "form_data": "{bad", "datasource_id": 5}]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(55))["charts"][0]["dataset_id"] == 5
# #endregion test_detail_form_data_bad_json
# #region test_detail_dataset_obj_not_dict [C:2] [TYPE Function]
# @BRIEF Non-dict dataset entries are skipped.
@pytest.mark.asyncio
async def test_detail_dataset_obj_not_dict():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 56, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
c.client.request = AsyncMock(return_value={"result": ["not_dict"]})
assert (await c.get_dashboard_detail(56))["datasets"] == []
# #endregion test_detail_dataset_obj_not_dict
# #region test_detail_dataset_id_none [C:2] [TYPE Function]
# @BRIEF Dataset entries with no id field are skipped.
@pytest.mark.asyncio
async def test_detail_dataset_id_none():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 57, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
c.client.request = AsyncMock(return_value={"result": [{"table_name": "no_id"}]})
assert (await c.get_dashboard_detail(57))["datasets"] == []
# #endregion test_detail_dataset_id_none
# ── Layout fallback edge cases ──────────────────────────────────────────────
# #region test_detail_position_json_dict [C:2] [TYPE Function]
# @BRIEF Fallback extracts chart IDs when position_json is a dict.
@pytest.mark.asyncio
async def test_detail_position_json_dict():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 58, "dashboard_title": "D", "position_json": {"CHART-3": {"chartId": 3}}, "json_metadata": ""}})
c.get_chart = AsyncMock(return_value={"result": {"id": 3, "slice_name": "C3", "viz_type": "t", "datasource_id": None, "changed_on": "t", "description": "d"}})
c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []})
assert any(ch["id"] == 3 for ch in (await c.get_dashboard_detail(58))["charts"])
# #endregion test_detail_position_json_dict
# #region test_detail_json_metadata_str [C:2] [TYPE Function]
# @BRIEF Fallback extracts chart IDs from json_metadata string.
@pytest.mark.asyncio
async def test_detail_json_metadata_str():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 59, "dashboard_title": "D", "position_json": "", "json_metadata": '{"charts": [{"chartId": 8}]}'}})
c.get_chart = AsyncMock(return_value={"result": {"id": 8, "slice_name": "C8", "viz_type": "b", "datasource_id": None, "changed_on": "t", "description": "d"}})
c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []})
assert any(ch["id"] == 8 for ch in (await c.get_dashboard_detail(59))["charts"])
# #endregion test_detail_json_metadata_str
# #region test_detail_json_metadata_dict [C:2] [TYPE Function]
# @BRIEF json_metadata as dict is traversed for chart IDs.
@pytest.mark.asyncio
async def test_detail_json_metadata_dict():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 62, "dashboard_title": "D", "position_json": "", "json_metadata": {"charts": [{"chartId": 11}]}}})
c.get_chart = AsyncMock(return_value={"result": {"id": 11, "slice_name": "C11", "viz_type": "x", "datasource_id": None, "changed_on": "t", "description": "d"}})
c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []})
assert any(ch["id"] == 11 for ch in (await c.get_dashboard_detail(62))["charts"])
# #endregion test_detail_json_metadata_dict
# #region test_detail_position_json_bad_string [C:2] [TYPE Function]
# @BRIEF Invalid position_json string handled gracefully.
@pytest.mark.asyncio
async def test_detail_position_json_bad_string():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 61, "dashboard_title": "D", "position_json": "{bad!!", "json_metadata": ""}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []})
assert (await c.get_dashboard_detail(61))["charts"] == []
# #endregion test_detail_position_json_bad_string
# #region test_detail_json_metadata_bad_string [C:2] [TYPE Function]
# @BRIEF Invalid json_metadata string handled gracefully.
@pytest.mark.asyncio
async def test_detail_json_metadata_bad_string():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 63, "dashboard_title": "D", "position_json": "", "json_metadata": "not json{{{"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock(); c.client.request = AsyncMock(return_value={"result": []})
assert (await c.get_dashboard_detail(63))["charts"] == []
# #endregion test_detail_json_metadata_bad_string
# #region test_detail_fallback_chart_error [C:2] [TYPE Function]
# @BRIEF Fallback chart resolution continues on error.
@pytest.mark.asyncio
async def test_detail_fallback_chart_error():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 60, "dashboard_title": "D", "position_json": {"CHART-99": {"chartId": 99}}, "json_metadata": ""}})
c.get_chart = AsyncMock(side_effect=RuntimeError("fail")); c.get_dataset = AsyncMock()
c.client.request = AsyncMock(return_value={"result": []})
assert (await c.get_dashboard_detail(60))["charts"] == []
# #endregion test_detail_fallback_chart_error
# ── Type error edge cases ───────────────────────────────────────────────────
# #region test_detail_dict_datasource_type_error [C:2] [TYPE Function]
# @BRIEF Dict datasource with non-convertible id → dataset_id=None.
@pytest.mark.asyncio
async def test_detail_dict_datasource_type_error():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 64, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": [{"id": 20, "slice_name": "C", "form_data": {"datasource": {"id": [1, 2]}}, "datasource_id": None}]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(64))["charts"][0]["dataset_id"] is None
# #endregion test_detail_dict_datasource_type_error
# #region test_detail_datasource_id_type_error [C:2] [TYPE Function]
# @BRIEF datasource_id that can't convert to int → dataset_id=None.
@pytest.mark.asyncio
async def test_detail_datasource_id_type_error():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 65, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": [{"id": 21, "slice_name": "C", "form_data": {"datasource_id": [1]}, "datasource_id": None}]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(65))["charts"][0]["dataset_id"] is None
# #endregion test_detail_datasource_id_type_error
# #region test_detail_backfill_dataset_type_error [C:2] [TYPE Function]
# @BRIEF Non-int dataset_id from charts skipped in backfill.
@pytest.mark.asyncio
async def test_detail_backfill_dataset_type_error():
c = _make_client()
c.get_dashboard = AsyncMock(return_value={"result": {"id": 66, "dashboard_title": "D"}})
c.get_chart = AsyncMock(); c.get_dataset = AsyncMock()
_detail_mock(c, charts_resp={"result": [{"id": 22, "slice_name": "C", "form_data": {}, "datasource_id": "not_int"}]}, datasets_resp={"result": []})
assert (await c.get_dashboard_detail(66))["datasets"] == []
# #endregion test_detail_backfill_dataset_type_error
# ── export_dashboard ────────────────────────────────────────────────────────
# #region test_export_dashboard_success [C:2] [TYPE Function]
# @BRIEF export_dashboard returns (content, filename) for valid ZIP.
@pytest.mark.asyncio
async def test_export_dashboard_success():
c = _make_client()
resp = MagicMock(spec=httpx.Response)
resp.headers = {"Content-Type": "application/zip", "Content-Disposition": 'attachment; filename="d.zip"'}
resp.content = b"PK\x03\x04data"
c.client.request = AsyncMock(return_value=resp)
content, fname = await c.export_dashboard(5)
assert content == b"PK\x03\x04data" and fname == "d.zip"
# #endregion test_export_dashboard_success
# #region test_export_dashboard_invalid [C:2] [TYPE Function]
# @BRIEF export_dashboard raises SupersetAPIError for non-ZIP.
@pytest.mark.asyncio
async def test_export_dashboard_invalid():
c = _make_client()
resp = MagicMock(spec=httpx.Response)
resp.headers = {"Content-Type": "text/html"}; resp.content = b"<html>"
c.client.request = AsyncMock(return_value=resp)
with pytest.raises(SupersetAPIError):
await c.export_dashboard(5)
# #endregion test_export_dashboard_invalid
# ── import_dashboard ────────────────────────────────────────────────────────
# #region test_import_dashboard_success [C:2] [TYPE Function]
# @BRIEF import_dashboard validates file and delegates to _do_import.
@pytest.mark.asyncio
async def test_import_dashboard_success(tmp_path):
c = _make_client()
zp = _zip_with_metadata(tmp_path)
c.client.upload_file = AsyncMock(return_value={"status": "ok"})
assert await c.import_dashboard(str(zp)) == {"status": "ok"}
# #endregion test_import_dashboard_success
# #region test_import_dashboard_none [C:2] [TYPE Function]
# @BRIEF import_dashboard raises ValueError when file_name is None.
@pytest.mark.asyncio
async def test_import_dashboard_none():
c = _make_client()
with pytest.raises(ValueError, match="file_name cannot be None"):
await c.import_dashboard(None)
# #endregion test_import_dashboard_none
# #region test_import_retry_with_delete [C:2] [TYPE Function]
# @BRIEF import_dashboard deletes and retries when delete_before_reimport=True.
@pytest.mark.asyncio
async def test_import_retry_with_delete(tmp_path):
c = _make_client(); c.delete_before_reimport = True
zp = _zip_with_metadata(tmp_path, "retry.zip")
c.client.upload_file = AsyncMock(side_effect=[RuntimeError("conflict"), {"status": "ok"}])
c.delete_dashboard = AsyncMock(); c.get_dashboards = AsyncMock(return_value=(1, [{"id": 55}]))
assert await c.import_dashboard(str(zp), dash_slug="s") == {"status": "ok"}
c.delete_dashboard.assert_awaited_once_with(55)
# #endregion test_import_retry_with_delete
# #region test_import_no_retry [C:2] [TYPE Function]
# @BRIEF import_dashboard re-raises when delete_before_reimport=False.
@pytest.mark.asyncio
async def test_import_no_retry(tmp_path):
c = _make_client(); c.delete_before_reimport = False
zp = _zip_with_metadata(tmp_path, "nr.zip")
c.client.upload_file = AsyncMock(side_effect=RuntimeError("fail"))
with pytest.raises(RuntimeError, match="fail"):
await c.import_dashboard(str(zp))
# #endregion test_import_no_retry
# #region test_import_retry_no_target [C:2] [TYPE Function]
# @BRIEF import_dashboard re-raises when retry enabled but no target ID found.
@pytest.mark.asyncio
async def test_import_retry_no_target(tmp_path):
c = _make_client(); c.delete_before_reimport = True
zp = _zip_with_metadata(tmp_path, "nt.zip")
c.client.upload_file = AsyncMock(side_effect=RuntimeError("conflict"))
c.get_dashboards = AsyncMock(return_value=(0, []))
with pytest.raises(RuntimeError, match="conflict"):
await c.import_dashboard(str(zp), dash_slug="missing")
# #endregion test_import_retry_no_target
# ── delete_dashboard ────────────────────────────────────────────────────────
# #region test_delete_dashboard_success [C:2] [TYPE Function]
# @BRIEF delete_dashboard sends DELETE for given ID.
@pytest.mark.asyncio
async def test_delete_dashboard_success():
c = _make_client()
c.client.request = AsyncMock(return_value={"result": True})
await c.delete_dashboard(42)
c.client.request.assert_awaited_once_with(method="DELETE", endpoint="/dashboard/42")
# #endregion test_delete_dashboard_success
# #region test_delete_dashboard_unexpected [C:2] [TYPE Function]
# @BRIEF delete_dashboard handles result=False without raising.
@pytest.mark.asyncio
async def test_delete_dashboard_unexpected():
c = _make_client()
c.client.request = AsyncMock(return_value={"result": False})
await c.delete_dashboard(99) # no exception
# #endregion test_delete_dashboard_unexpected
# #endregion Test.SupersetClient.DashboardsCrud

View File

@@ -0,0 +1,156 @@
# #region Test.SupersetClient.DashboardsList [C:3] [TYPE Module] [SEMANTICS test,superset,dashboard,list]
# @BRIEF Unit tests for dashboard listing mixin — paginated list, summary projection.
# @RELATION BINDS_TO -> [SupersetDashboardsListMixin]
# @TEST_EDGE: empty_dashboards -> get_dashboards returns (0, [])
from unittest.mock import AsyncMock, MagicMock
import pytest
from src.core.config_models import Environment
from src.core.superset_client import SupersetClient
def _make_client() -> SupersetClient:
env = Environment(id="env1", name="T", url="http://test.local", username="u", password="p")
client = SupersetClient(env)
mc = MagicMock()
mc.request = AsyncMock(return_value={})
mc.fetch_paginated_data = AsyncMock(return_value=[])
client.client = mc
client.network = mc
return client
# ── get_dashboards ──────────────────────────────────────────────────────────
# #region test_get_dashboards_success [C:2] [TYPE Function]
# @BRIEF get_dashboards returns (count, data) from paginated fetch.
@pytest.mark.asyncio
async def test_get_dashboards_success():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[{"id": 1}, {"id": 2}, {"id": 3}])
count, data = await client.get_dashboards()
assert count == 3
assert len(data) == 3
# #endregion test_get_dashboards_success
# #region test_get_dashboards_empty [C:2] [TYPE Function]
# @BRIEF get_dashboards returns (0, []) when no dashboards exist.
@pytest.mark.asyncio
async def test_get_dashboards_empty():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[])
count, data = await client.get_dashboards()
assert count == 0
assert data == []
# #endregion test_get_dashboards_empty
# #region test_get_dashboards_with_query [C:2] [TYPE Function]
# @BRIEF get_dashboards passes custom query columns through.
@pytest.mark.asyncio
async def test_get_dashboards_with_query():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[{"id": 1}])
await client.get_dashboards(query={"columns": ["id", "slug"]})
call_kwargs = client.client.fetch_paginated_data.call_args
base_query = call_kwargs.kwargs["pagination_options"]["base_query"]
assert base_query["columns"] == ["id", "slug"]
# #endregion test_get_dashboards_with_query
# ── get_dashboards_page ─────────────────────────────────────────────────────
# #region test_get_dashboards_page [C:2] [TYPE Function]
# @BRIEF get_dashboards_page returns (count, result) from single API call.
@pytest.mark.asyncio
async def test_get_dashboards_page():
client = _make_client()
client.client.request = AsyncMock(return_value={"result": [{"id": 1}], "count": 50})
total, data = await client.get_dashboards_page()
assert total == 50
assert len(data) == 1
# #endregion test_get_dashboards_page
# #region test_get_dashboards_page_no_count [C:2] [TYPE Function]
# @BRIEF get_dashboards_page falls back to len(result) when count is absent.
@pytest.mark.asyncio
async def test_get_dashboards_page_no_count():
client = _make_client()
client.client.request = AsyncMock(return_value={"result": [{"id": 1}, {"id": 2}]})
total, data = await client.get_dashboards_page()
assert total == 2
# #endregion test_get_dashboards_page_no_count
# ── get_dashboards_summary ──────────────────────────────────────────────────
# #region test_get_dashboards_summary [C:2] [TYPE Function]
# @BRIEF get_dashboards_summary projects owner/creator fields correctly.
@pytest.mark.asyncio
async def test_get_dashboards_summary():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[{
"id": 1, "slug": "s1", "dashboard_title": "T1", "url": "/u",
"changed_on_utc": "2025-01-01", "published": True,
"owners": [{"username": "alice", "first_name": "Alice", "last_name": "A"}],
"created_by": {"username": "bob"}, "changed_by": {"username": "carol"},
"changed_by_name": "Carol",
}])
result = await client.get_dashboards_summary()
assert len(result) == 1
assert result[0]["status"] == "published"
assert result[0]["owners"] == ["Alice A"]
# #endregion test_get_dashboards_summary
# #region test_get_dashboards_summary_draft [C:2] [TYPE Function]
# @BRIEF get_dashboards_summary marks unpublished dashboards as 'draft'.
@pytest.mark.asyncio
async def test_get_dashboards_summary_draft():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[{
"id": 2, "slug": "s2", "dashboard_title": "Draft", "published": False,
"owners": None, "created_by": None, "changed_by": None, "changed_by_name": None,
}])
result = await client.get_dashboards_summary()
assert result[0]["status"] == "draft"
# #endregion test_get_dashboards_summary_draft
# #region test_get_dashboards_summary_require_slug [C:2] [TYPE Function]
# @BRIEF get_dashboards_summary adds slug filter when require_slug=True.
@pytest.mark.asyncio
async def test_get_dashboards_summary_require_slug():
client = _make_client()
client.client.fetch_paginated_data = AsyncMock(return_value=[])
await client.get_dashboards_summary(require_slug=True)
call_kwargs = client.client.fetch_paginated_data.call_args
base_query = call_kwargs.kwargs["pagination_options"]["base_query"]
assert any(f["col"] == "slug" for f in base_query.get("filters", []))
# #endregion test_get_dashboards_summary_require_slug
# ── get_dashboards_summary_page ─────────────────────────────────────────────
# #region test_get_dashboards_summary_page [C:2] [TYPE Function]
# @BRIEF get_dashboards_summary_page returns projected page with search filter.
@pytest.mark.asyncio
async def test_get_dashboards_summary_page():
client = _make_client()
client.client.request = AsyncMock(return_value={
"result": [{"id": 1, "slug": "s", "dashboard_title": "Sales", "published": True,
"owners": [{"username": "admin"}], "created_by": None, "changed_by": None, "changed_by_name": None}],
"count": 1,
})
total, data = await client.get_dashboards_summary_page(page=1, page_size=20, search="Sales")
assert total == 1
assert data[0]["title"] == "Sales"
# #endregion test_get_dashboards_summary_page
# #region test_get_dashboards_summary_page_no_search [C:2] [TYPE Function]
# @BRIEF get_dashboards_summary_page works without search term.
@pytest.mark.asyncio
async def test_get_dashboards_summary_page_no_search():
client = _make_client()
client.client.request = AsyncMock(return_value={"result": [], "count": 0})
total, data = await client.get_dashboards_summary_page(page=1, page_size=10)
assert total == 0
assert data == []
# #endregion test_get_dashboards_summary_page_no_search
# #endregion Test.SupersetClient.DashboardsList

View File

@@ -0,0 +1,858 @@
# #region Test.Core.ConfigManager [C:3] [TYPE Module] [SEMANTICS test,config,manager]
# @BRIEF Unit tests for ConfigManager: init, load, save, environment CRUD, encryption, legacy migration.
# @RELATION BINDS_TO -> [ConfigManager]
# @TEST_EDGE: invalid_config_path -> ValueError on empty/non-string path
# @TEST_EDGE: missing_field -> legacy file absent falls back to default
# @TEST_EDGE: invalid_type -> save_config rejects non-AppConfig/non-dict
# @TEST_EDGE: external_fail -> DB save failure triggers rollback
from pathlib import Path
import sys
from unittest.mock import MagicMock, mock_open, patch
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import pytest
from src.core.config_models import AppConfig, Environment, FeaturesConfig, GlobalSettings
# Hardcoded fixture — frozen Fernet key for deterministic encryption tests.
_VALID_FERNET_KEY = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
_MODULE = "src.core.config_manager"
def _make_env(env_id="env1", name="Env1", password="secret", is_default=False):
return Environment(id=env_id, name=name, url="http://x", username="u", password=password, is_default=is_default)
def _make_config(envs=None, settings=None):
return AppConfig(environments=envs or [], settings=settings or GlobalSettings())
def _build_manager(mock_session_local=None, config_path="config.json"):
"""Build a ConfigManager bypassing __init__ side effects. Patches NOT active after return."""
from src.core.config_manager import ConfigManager
mgr = object.__new__(ConfigManager)
mgr.config_path = Path(config_path)
mgr.raw_payload = {}
mgr.config = _make_config()
return mgr
# ── __init__ ────────────────────────────────────────────────────────────────
class TestInit:
# #region test_init_invalid_path_empty [C:2] [TYPE Function]
# @BRIEF Empty string config_path raises ValueError.
def test_init_invalid_path_empty(self):
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.logger") as m_log:
from src.core.config_manager import ConfigManager
with pytest.raises(ValueError, match="non-empty string"):
ConfigManager(config_path="")
# #endregion test_init_invalid_path_empty
# #region test_init_invalid_path_type [C:2] [TYPE Function]
# @BRIEF Non-string config_path raises ValueError.
def test_init_invalid_path_type(self):
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.logger"):
from src.core.config_manager import ConfigManager
with pytest.raises(ValueError, match="non-empty string"):
ConfigManager(config_path=123)
# #endregion test_init_invalid_path_type
# ── _get_encryption_manager ─────────────────────────────────────────────────
class TestGetEncryptionManager:
# #region test_encryption_manager_init_failure [C:2] [TYPE Function]
# @BRIEF EncryptionManager RuntimeError yields None with cached attribute.
def test_encryption_manager_init_failure(self):
mgr = _build_manager(MagicMock())
with patch(f"{_MODULE}.EncryptionManager", side_effect=RuntimeError("no key")):
result = mgr._get_encryption_manager()
assert result is None
assert mgr._encryption is None
# #endregion test_encryption_manager_init_failure
# #region test_encryption_manager_success [C:2] [TYPE Function]
# @BRIEF EncryptionManager init succeeds and is cached.
def test_encryption_manager_success(self):
mgr = _build_manager(MagicMock())
fake_em = MagicMock()
with patch(f"{_MODULE}.EncryptionManager", return_value=fake_em):
result = mgr._get_encryption_manager()
assert result is fake_em
# Second call returns cached
assert mgr._get_encryption_manager() is fake_em
# #endregion test_encryption_manager_success
# ── _encrypt_env_passwords ──────────────────────────────────────────────────
class TestEncryptEnvPasswords:
# #region test_encrypt_no_encryption [C:2] [TYPE Function]
# @BRIEF No encryption manager → passwords unchanged.
def test_encrypt_no_encryption(self):
mgr = _build_manager(MagicMock())
mgr._encryption = None
payload = {"environments": [{"password": "secret"}]}
mgr._encrypt_env_passwords(payload)
assert payload["environments"][0]["password"] == "secret"
# #endregion test_encrypt_no_encryption
# #region test_encrypt_non_list_environments [C:2] [TYPE Function]
# @BRIEF Non-list environments key → no-op.
def test_encrypt_non_list_environments(self):
mgr = _build_manager(MagicMock())
mgr._encryption = MagicMock()
payload = {"environments": "not-a-list"}
mgr._encrypt_env_passwords(payload) # should not raise
# #endregion test_encrypt_non_list_environments
# #region test_encrypt_skips_masked_and_empty [C:2] [TYPE Function]
# @BRIEF Masked (********) and empty passwords are skipped.
def test_encrypt_skips_masked_and_empty(self):
mgr = _build_manager(MagicMock())
em = MagicMock()
mgr._encryption = em
payload = {"environments": [{"password": ""}, {"password": "********"}]}
mgr._encrypt_env_passwords(payload)
em.encrypt.assert_not_called()
# #endregion test_encrypt_skips_masked_and_empty
# #region test_encrypt_skips_already_encrypted [C:2] [TYPE Function]
# @BRIEF Password that decrypts successfully is already encrypted → skip.
def test_encrypt_skips_already_encrypted(self):
mgr = _build_manager(MagicMock())
em = MagicMock()
em.decrypt.return_value = "plaintext"
mgr._encryption = em
payload = {"environments": [{"password": "already-encrypted-token"}]}
mgr._encrypt_env_passwords(payload)
em.encrypt.assert_not_called()
# #endregion test_encrypt_skips_already_encrypted
# #region test_encrypt_success [C:2] [TYPE Function]
# @BRIEF Plain password is encrypted in-place.
def test_encrypt_success(self):
mgr = _build_manager(MagicMock())
em = MagicMock()
em.decrypt.side_effect = Exception("not encrypted")
em.encrypt.return_value = "encrypted-token"
mgr._encryption = em
payload = {"environments": [{"password": "plain"}]}
mgr._encrypt_env_passwords(payload)
assert payload["environments"][0]["password"] == "encrypted-token"
# #endregion test_encrypt_success
# #region test_encrypt_failure_logs [C:2] [TYPE Function]
# @BRIEF Encryption failure logs and leaves password unchanged.
def test_encrypt_failure_logs(self):
mgr = _build_manager(MagicMock())
em = MagicMock()
em.decrypt.side_effect = Exception("not encrypted")
em.encrypt.side_effect = RuntimeError("boom")
mgr._encryption = em
payload = {"environments": [{"password": "plain"}]}
with patch(f"{_MODULE}.logger"):
mgr._encrypt_env_passwords(payload)
assert payload["environments"][0]["password"] == "plain"
# #endregion test_encrypt_failure_logs
# ── _decrypt_env_passwords ──────────────────────────────────────────────────
class TestDecryptEnvPasswords:
# #region test_decrypt_no_encryption [C:2] [TYPE Function]
# @BRIEF No encryption manager → passwords unchanged.
def test_decrypt_no_encryption(self):
mgr = _build_manager(MagicMock())
mgr._encryption = None
config = _make_config([_make_env(password="secret")])
mgr._decrypt_env_passwords(config)
assert config.environments[0].password == "secret"
# #endregion test_decrypt_no_encryption
# #region test_decrypt_empty_password [C:2] [TYPE Function]
# @BRIEF Empty password is skipped.
def test_decrypt_empty_password(self):
mgr = _build_manager(MagicMock())
mgr._encryption = MagicMock()
config = _make_config([_make_env(password="")])
mgr._decrypt_env_passwords(config)
mgr._encryption.decrypt.assert_not_called()
# #endregion test_decrypt_empty_password
# #region test_decrypt_success [C:2] [TYPE Function]
# @BRIEF Encrypted password is decrypted in-place.
def test_decrypt_success(self):
mgr = _build_manager(MagicMock())
em = MagicMock()
em.decrypt.return_value = "decrypted"
mgr._encryption = em
config = _make_config([_make_env(password="encrypted-token")])
mgr._decrypt_env_passwords(config)
assert config.environments[0].password == "decrypted"
# #endregion test_decrypt_success
# #region test_decrypt_failure_keeps_plaintext [C:2] [TYPE Function]
# @BRIEF Decrypt failure assumes plaintext legacy data → password unchanged.
def test_decrypt_failure_keeps_plaintext(self):
mgr = _build_manager(MagicMock())
em = MagicMock()
em.decrypt.side_effect = Exception("bad data")
mgr._encryption = em
config = _make_config([_make_env(password="legacy-plain")])
mgr._decrypt_env_passwords(config)
assert config.environments[0].password == "legacy-plain"
# #endregion test_decrypt_failure_keeps_plaintext
# ── _apply_features_from_env ────────────────────────────────────────────────
class TestApplyFeaturesFromEnv:
# #region test_apply_timezone_from_env [C:2] [TYPE Function]
# @BRIEF APP_TIMEZONE env var overrides settings.app_timezone.
def test_apply_timezone_from_env(self):
from src.core.config_manager import ConfigManager
settings = GlobalSettings()
with patch.dict("os.environ", {"APP_TIMEZONE": "US/Eastern"}), \
patch(f"{_MODULE}.belief_scope"):
ConfigManager._apply_features_from_env(settings)
assert settings.app_timezone == "US/Eastern"
# #endregion test_apply_timezone_from_env
# #region test_apply_feature_flags_from_env [C:2] [TYPE Function]
# @BRIEF FEATURES__* env vars override feature flags.
def test_apply_feature_flags_from_env(self):
from src.core.config_manager import ConfigManager
settings = GlobalSettings()
with patch.dict("os.environ", {"FEATURES__TRANSLATE": "false"}), \
patch(f"{_MODULE}.belief_scope"):
ConfigManager._apply_features_from_env(settings)
assert settings.features.translate is False
# #endregion test_apply_feature_flags_from_env
# ── _load_from_legacy_file ──────────────────────────────────────────────────
class TestLoadFromLegacyFile:
# #region test_legacy_file_not_found [C:2] [TYPE Function]
# @BRIEF Missing legacy file returns empty dict.
def test_legacy_file_not_found(self):
mgr = _build_manager(MagicMock(), config_path="/nonexistent/config.json")
with patch(f"{_MODULE}.belief_scope"):
result = mgr._load_from_legacy_file()
assert result == {}
# #endregion test_legacy_file_not_found
# #region test_legacy_file_valid_json [C:2] [TYPE Function]
# @BRIEF Valid JSON object loaded from legacy file.
def test_legacy_file_valid_json(self, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text('{"environments": [], "settings": {}}')
mgr = _build_manager(MagicMock(), config_path=str(cfg))
with patch(f"{_MODULE}.belief_scope"):
result = mgr._load_from_legacy_file()
assert result == {"environments": [], "settings": {}}
# #endregion test_legacy_file_valid_json
# #region test_legacy_file_non_dict [C:2] [TYPE Function]
# @BRIEF Non-dict JSON payload raises ValueError.
def test_legacy_file_non_dict(self, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text('"just a string"')
mgr = _build_manager(MagicMock(), config_path=str(cfg))
with patch(f"{_MODULE}.belief_scope"):
with pytest.raises(ValueError, match="JSON object"):
mgr._load_from_legacy_file()
# #endregion test_legacy_file_non_dict
# ── _load_config ────────────────────────────────────────────────────────────
class TestLoadConfig:
# #region test_load_from_db [C:2] [TYPE Function]
# @BRIEF DB record present → config loaded from DB payload.
def test_load_from_db(self):
mock_session = MagicMock()
record = MagicMock()
record.id = "global"
record.payload = {"environments": [], "settings": {}}
mock_session.query.return_value.filter.return_value.first.return_value = record
mock_sl = MagicMock(return_value=mock_session)
mgr = _build_manager()
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl), \
patch.object(mgr, "_decrypt_env_passwords"), \
patch.object(mgr, "_sync_environment_records"):
config = mgr._load_config()
assert isinstance(config, AppConfig)
mock_session.commit.assert_called_once()
# #endregion test_load_from_db
# #region test_load_from_legacy_migration [C:2] [TYPE Function]
# @BRIEF No DB record + legacy file present → migration path.
def test_load_from_legacy_migration(self, tmp_path):
cfg = tmp_path / "config.json"
cfg.write_text('{"environments": [], "settings": {}}')
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_sl = MagicMock(return_value=mock_session)
mgr = _build_manager(config_path=str(cfg))
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl), \
patch.object(mgr, "_decrypt_env_passwords"), \
patch.object(mgr, "_apply_features_from_env"), patch.object(mgr, "_save_config_to_db"):
config = mgr._load_config()
assert isinstance(config, AppConfig)
# #endregion test_load_from_legacy_migration
# #region test_load_default_fallback [C:2] [TYPE Function]
# @BRIEF No DB record + no legacy file → default config.
def test_load_default_fallback(self):
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_sl = MagicMock(return_value=mock_session)
mgr = _build_manager(config_path="/nonexistent/config.json")
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl), \
patch.object(mgr, "_save_config_to_db"):
config = mgr._load_config()
assert isinstance(config, AppConfig)
assert config.environments == []
# #endregion test_load_default_fallback
# #region test_load_recoverable_error [C:2] [TYPE Function]
# @BRIEF JSON/Type/Value error during load → default config fallback.
def test_load_recoverable_error(self):
mock_session = MagicMock()
mock_session.query.side_effect = ValueError("bad data")
mock_sl = MagicMock(return_value=mock_session)
mgr = _build_manager()
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl):
config = mgr._load_config()
assert isinstance(config, AppConfig)
# #endregion test_load_recoverable_error
# #region test_load_critical_error_reraises [C:2] [TYPE Function]
# @BRIEF Non-recoverable exception during load is re-raised.
def test_load_critical_error_reraises(self):
mock_session = MagicMock()
mock_session.query.side_effect = RuntimeError("critical")
mock_sl = MagicMock(return_value=mock_session)
mgr = _build_manager()
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl):
with pytest.raises(RuntimeError, match="critical"):
mgr._load_config()
# #endregion test_load_critical_error_reraises
# ── _sync_environment_records ───────────────────────────────────────────────
class TestSyncEnvironmentRecords:
# #region test_sync_creates_new_records [C:2] [TYPE Function]
# @BRIEF New environments are added as DB records.
def test_sync_creates_new_records(self):
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = []
mgr = _build_manager(MagicMock())
config = _make_config([_make_env("env1", "Env One")])
with patch(f"{_MODULE}.belief_scope"):
mgr._sync_environment_records(mock_session, config)
mock_session.add.assert_called_once()
# #endregion test_sync_creates_new_records
# #region test_sync_updates_existing_records [C:2] [TYPE Function]
# @BRIEF Existing environment records are updated in-place.
def test_sync_updates_existing_records(self):
existing = MagicMock()
existing.id = "env1"
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = [existing]
mgr = _build_manager(MagicMock())
config = _make_config([_make_env("env1", "Updated Name")])
with patch(f"{_MODULE}.belief_scope"):
mgr._sync_environment_records(mock_session, config)
assert existing.name == "Updated Name"
mock_session.add.assert_not_called()
# #endregion test_sync_updates_existing_records
# #region test_sync_skips_empty_id [C:2] [TYPE Function]
# @BRIEF Environments with empty id are skipped.
def test_sync_skips_empty_id(self):
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = []
mgr = _build_manager(MagicMock())
config = _make_config([_make_env("", "NoID")])
with patch(f"{_MODULE}.belief_scope"):
mgr._sync_environment_records(mock_session, config)
mock_session.add.assert_not_called()
# #endregion test_sync_skips_empty_id
# ── _delete_stale_environment_records ───────────────────────────────────────
class TestDeleteStaleEnvironmentRecords:
# #region test_delete_stale_records [C:2] [TYPE Function]
# @BRIEF Persisted records not in config are deleted.
def test_delete_stale_records(self):
stale = MagicMock()
stale.id = "stale-env"
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = [stale]
mgr = _build_manager(MagicMock())
config = _make_config([_make_env("env1")])
with patch(f"{_MODULE}.belief_scope"):
mgr._delete_stale_environment_records(mock_session, config)
mock_session.delete.assert_called_once_with(stale)
# #endregion test_delete_stale_records
# #region test_delete_no_stale [C:2] [TYPE Function]
# @BRIEF All persisted records match config → nothing deleted.
def test_delete_no_stale(self):
rec = MagicMock()
rec.id = "env1"
mock_session = MagicMock()
mock_session.query.return_value.all.return_value = [rec]
mgr = _build_manager(MagicMock())
config = _make_config([_make_env("env1")])
with patch(f"{_MODULE}.belief_scope"):
mgr._delete_stale_environment_records(mock_session, config)
mock_session.delete.assert_not_called()
# #endregion test_delete_no_stale
# ── _save_config_to_db ──────────────────────────────────────────────────────
class TestSaveConfigToDb:
# #region test_save_creates_new_record [C:2] [TYPE Function]
# @BRIEF No existing record → new AppConfigRecord created.
def test_save_creates_new_record(self):
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mgr = _build_manager(MagicMock())
config = _make_config()
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_encrypt_env_passwords"), \
patch.object(mgr, "_decrypt_env_passwords"), patch.object(mgr, "_sync_environment_records"), \
patch.object(mgr, "_delete_stale_environment_records"):
mgr._save_config_to_db(config, session=mock_session)
mock_session.add.assert_called_once()
mock_session.commit.assert_called_once()
# #endregion test_save_creates_new_record
# #region test_save_updates_existing_record [C:2] [TYPE Function]
# @BRIEF Existing record → payload updated in-place.
def test_save_updates_existing_record(self):
record = MagicMock()
record.id = "global"
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = record
mgr = _build_manager(MagicMock())
config = _make_config()
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_encrypt_env_passwords"), \
patch.object(mgr, "_decrypt_env_passwords"), patch.object(mgr, "_sync_environment_records"), \
patch.object(mgr, "_delete_stale_environment_records"):
mgr._save_config_to_db(config, session=mock_session)
assert record.payload is not None
mock_session.add.assert_not_called()
# #endregion test_save_updates_existing_record
# #region test_save_rollback_on_error [C:2] [TYPE Function]
# @BRIEF DB commit failure → rollback and re-raise.
def test_save_rollback_on_error(self):
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_session.commit.side_effect = RuntimeError("db error")
mgr = _build_manager(MagicMock())
config = _make_config()
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_encrypt_env_passwords"), \
patch.object(mgr, "_sync_environment_records"), \
patch.object(mgr, "_delete_stale_environment_records"):
with pytest.raises(RuntimeError, match="db error"):
mgr._save_config_to_db(config, session=mock_session)
mock_session.rollback.assert_called_once()
# #endregion test_save_rollback_on_error
# #region test_save_owns_session_closes [C:2] [TYPE Function]
# @BRIEF When no session provided, manager creates and closes its own.
def test_save_owns_session_closes(self):
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
mock_sl = MagicMock(return_value=mock_session)
mgr = _build_manager()
config = _make_config()
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.SessionLocal", mock_sl), \
patch.object(mgr, "_encrypt_env_passwords"), \
patch.object(mgr, "_decrypt_env_passwords"), patch.object(mgr, "_sync_environment_records"), \
patch.object(mgr, "_delete_stale_environment_records"):
mgr._save_config_to_db(config, session=None)
mock_session.close.assert_called_once()
# #endregion test_save_owns_session_closes
# ── save / get_config / get_payload ─────────────────────────────────────────
class TestPublicAccessors:
# #region test_save_delegates [C:2] [TYPE Function]
# @BRIEF save() delegates to _save_config_to_db with current config.
def test_save_delegates(self):
mgr = _build_manager(MagicMock())
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_save_config_to_db") as m:
mgr.save()
m.assert_called_once_with(mgr.config)
# #endregion test_save_delegates
# #region test_get_config [C:2] [TYPE Function]
# @BRIEF get_config returns current in-memory config.
def test_get_config(self):
mgr = _build_manager(MagicMock())
assert mgr.get_config() is mgr.config
# #endregion test_get_config
# #region test_get_payload [C:2] [TYPE Function]
# @BRIEF get_payload syncs and returns raw payload.
def test_get_payload(self):
mgr = _build_manager(MagicMock())
with patch(f"{_MODULE}.belief_scope"):
result = mgr.get_payload()
assert isinstance(result, dict)
assert "environments" in result
# #endregion test_get_payload
# ── save_config ─────────────────────────────────────────────────────────────
class TestSaveConfig:
# #region test_save_config_typed [C:2] [TYPE Function]
# @BRIEF AppConfig input → saved and returned.
def test_save_config_typed(self):
mgr = _build_manager(MagicMock())
config = _make_config()
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_save_config_to_db"):
result = mgr.save_config(config)
assert result is config
# #endregion test_save_config_typed
# #region test_save_config_dict [C:2] [TYPE Function]
# @BRIEF Dict input → validated and saved.
def test_save_config_dict(self):
mgr = _build_manager(MagicMock())
payload = {"environments": [], "settings": {}}
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "_save_config_to_db"):
result = mgr.save_config(payload)
assert isinstance(result, AppConfig)
# #endregion test_save_config_dict
# #region test_save_config_invalid_type [C:2] [TYPE Function]
# @BRIEF Non-AppConfig/non-dict → TypeError.
def test_save_config_invalid_type(self):
mgr = _build_manager(MagicMock())
with patch(f"{_MODULE}.belief_scope"):
with pytest.raises(TypeError, match="AppConfig or dict"):
mgr.save_config("invalid")
# #endregion test_save_config_invalid_type
# ── update_global_settings ──────────────────────────────────────────────────
class TestUpdateGlobalSettings:
# #region test_update_settings [C:2] [TYPE Function]
# @BRIEF Settings replaced and saved.
def test_update_settings(self):
mgr = _build_manager(MagicMock())
new_settings = GlobalSettings(app_timezone="US/Pacific")
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
result = mgr.update_global_settings(new_settings)
assert result.settings.app_timezone == "US/Pacific"
# #endregion test_update_settings
# ── validate_path ───────────────────────────────────────────────────────────
class TestValidatePath:
# #region test_validate_path_success [C:2] [TYPE Function]
# @BRIEF Writable directory → (True, "OK").
def test_validate_path_success(self, tmp_path):
mgr = _build_manager(MagicMock())
with patch(f"{_MODULE}.belief_scope"):
ok, msg = mgr.validate_path(str(tmp_path))
assert ok is True
assert msg == "OK"
# #endregion test_validate_path_success
# #region test_validate_path_not_exist [C:2] [TYPE Function]
# @BRIEF Path does not exist after mkdir attempt → (False, ...).
def test_validate_path_not_exist(self):
mgr = _build_manager(MagicMock())
fake_path = MagicMock()
fake_path.expanduser.return_value = fake_path
fake_path.exists.return_value = False
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.Path", return_value=fake_path):
ok, msg = mgr.validate_path("/nonexistent/path")
assert ok is False
assert "does not exist" in msg
# #endregion test_validate_path_not_exist
# #region test_validate_path_not_dir [C:2] [TYPE Function]
# @BRIEF Path is a file, not a directory → (False, ...).
def test_validate_path_not_dir(self):
mgr = _build_manager(MagicMock())
fake_path = MagicMock()
fake_path.expanduser.return_value = fake_path
fake_path.exists.return_value = True
fake_path.is_dir.return_value = False
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.Path", return_value=fake_path):
ok, msg = mgr.validate_path("/some/file")
assert ok is False
assert "not a directory" in msg
# #endregion test_validate_path_not_dir
# #region test_validate_path_exception [C:2] [TYPE Function]
# @BRIEF Exception during validation → (False, error message).
def test_validate_path_exception(self):
mgr = _build_manager(MagicMock())
fake_path = MagicMock()
fake_path.expanduser.side_effect = PermissionError("denied")
with patch(f"{_MODULE}.belief_scope"), patch(f"{_MODULE}.Path", return_value=fake_path):
ok, msg = mgr.validate_path("/restricted/path")
assert ok is False
assert "denied" in msg
# #endregion test_validate_path_exception
# ── get_environments / has_environments / get_environment ────────────────────
class TestEnvironmentAccessors:
# #region test_get_environments [C:2] [TYPE Function]
# @BRIEF Returns list copy of environments.
def test_get_environments(self):
env = _make_env()
mgr = _build_manager(MagicMock())
mgr.config = _make_config([env])
with patch(f"{_MODULE}.belief_scope"):
result = mgr.get_environments()
assert len(result) == 1
assert result[0].id == "env1"
# #endregion test_get_environments
# #region test_has_environments_true [C:2] [TYPE Function]
# @BRIEF Returns True when environments exist.
def test_has_environments_true(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env()])
with patch(f"{_MODULE}.belief_scope"):
assert mgr.has_environments() is True
# #endregion test_has_environments_true
# #region test_has_environments_false [C:2] [TYPE Function]
# @BRIEF Returns False when no environments.
def test_has_environments_false(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([])
with patch(f"{_MODULE}.belief_scope"):
assert mgr.has_environments() is False
# #endregion test_has_environments_false
# #region test_get_environment_by_id [C:2] [TYPE Function]
# @BRIEF Lookup by id returns matching environment.
def test_get_environment_by_id(self):
env = _make_env("env1", "Env One")
mgr = _build_manager(MagicMock())
mgr.config = _make_config([env])
with patch(f"{_MODULE}.belief_scope"):
assert mgr.get_environment("env1") is env
# #endregion test_get_environment_by_id
# #region test_get_environment_by_name [C:2] [TYPE Function]
# @BRIEF Lookup by name returns matching environment.
def test_get_environment_by_name(self):
env = _make_env("env1", "Env One")
mgr = _build_manager(MagicMock())
mgr.config = _make_config([env])
with patch(f"{_MODULE}.belief_scope"):
assert mgr.get_environment("Env One") is env
# #endregion test_get_environment_by_name
# #region test_get_environment_not_found [C:2] [TYPE Function]
# @BRIEF Unknown id → None.
def test_get_environment_not_found(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env()])
with patch(f"{_MODULE}.belief_scope"):
assert mgr.get_environment("nonexistent") is None
# #endregion test_get_environment_not_found
# #region test_get_environment_empty_id [C:2] [TYPE Function]
# @BRIEF Empty/None id → None.
def test_get_environment_empty_id(self):
mgr = _build_manager(MagicMock())
with patch(f"{_MODULE}.belief_scope"):
assert mgr.get_environment("") is None
assert mgr.get_environment(None) is None
# #endregion test_get_environment_empty_id
# ── add_environment ─────────────────────────────────────────────────────────
class TestAddEnvironment:
# #region test_add_new_environment [C:2] [TYPE Function]
# @BRIEF New environment appended and saved.
def test_add_new_environment(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([])
env = _make_env("new1", "New Env")
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
result = mgr.add_environment(env)
assert len(result.environments) == 1
assert result.environments[0].id == "new1"
# #endregion test_add_new_environment
# #region test_add_replaces_existing [C:2] [TYPE Function]
# @BRIEF Existing id → replaced in-place.
def test_add_replaces_existing(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env("env1", "Old")])
env = _make_env("env1", "New")
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
result = mgr.add_environment(env)
assert len(result.environments) == 1
assert result.environments[0].name == "New"
# #endregion test_add_replaces_existing
# #region test_add_default_clears_others [C:2] [TYPE Function]
# @BRIEF Adding a default env clears is_default on others.
def test_add_default_clears_others(self):
mgr = _build_manager(MagicMock())
old = _make_env("env1", is_default=True)
mgr.config = _make_config([old])
new = _make_env("env2", is_default=True)
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
mgr.add_environment(new)
assert mgr.config.environments[0].is_default is False
assert mgr.config.environments[1].is_default is True
# #endregion test_add_default_clears_others
# #region test_add_first_env_becomes_default [C:2] [TYPE Function]
# @BRIEF First environment auto-becomes default if none marked.
def test_add_first_env_becomes_default(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([])
env = _make_env("env1", is_default=False)
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
mgr.add_environment(env)
assert mgr.config.environments[0].is_default is True
# #endregion test_add_first_env_becomes_default
# ── update_environment ──────────────────────────────────────────────────────
class TestUpdateEnvironment:
# #region test_update_existing [C:2] [TYPE Function]
# @BRIEF Existing env updated and saved.
def test_update_existing(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env("env1", "Old", password="real")])
updated = _make_env("env1", "New", password="new-pwd")
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
result = mgr.update_environment("env1", updated)
assert result is True
assert mgr.config.environments[0].name == "New"
# #endregion test_update_existing
# #region test_update_masked_password_preserved [C:2] [TYPE Function]
# @BRIEF Masked password (********) preserves existing password.
def test_update_masked_password_preserved(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env("env1", password="real-secret")])
updated = _make_env("env1", password="********")
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
mgr.update_environment("env1", updated)
assert mgr.config.environments[0].password == "real-secret"
# #endregion test_update_masked_password_preserved
# #region test_update_not_found [C:2] [TYPE Function]
# @BRIEF Unknown env_id → returns False.
def test_update_not_found(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env("env1")])
with patch(f"{_MODULE}.belief_scope"):
result = mgr.update_environment("nonexistent", _make_env("env2"))
assert result is False
# #endregion test_update_not_found
# #region test_update_default_preservation [C:2] [TYPE Function]
# @BRIEF Removing is_default from the only default keeps it default.
def test_update_default_preservation(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env("env1", is_default=True)])
updated = _make_env("env1", is_default=False)
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
mgr.update_environment("env1", updated)
assert mgr.config.environments[0].is_default is True
# #endregion test_update_default_preservation
# #region test_update_sets_default_clears_others [C:2] [TYPE Function]
# @BRIEF Setting is_default=True on update clears is_default on all others.
def test_update_sets_default_clears_others(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([
_make_env("env1", is_default=True),
_make_env("env2", is_default=False),
])
updated = _make_env("env2", is_default=True)
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
mgr.update_environment("env2", updated)
assert mgr.config.environments[0].is_default is False
assert mgr.config.environments[1].is_default is True
# #endregion test_update_sets_default_clears_others
# ── delete_environment ──────────────────────────────────────────────────────
class TestDeleteEnvironment:
# #region test_delete_existing [C:2] [TYPE Function]
# @BRIEF Existing env deleted and saved.
def test_delete_existing(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env("env1"), _make_env("env2")])
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
result = mgr.delete_environment("env1")
assert result is True
assert len(mgr.config.environments) == 1
assert mgr.config.environments[0].id == "env2"
# #endregion test_delete_existing
# #region test_delete_not_found [C:2] [TYPE Function]
# @BRIEF Unknown env_id → returns False.
def test_delete_not_found(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env("env1")])
with patch(f"{_MODULE}.belief_scope"):
result = mgr.delete_environment("nonexistent")
assert result is False
# #endregion test_delete_not_found
# #region test_delete_default_reassigns [C:2] [TYPE Function]
# @BRIEF Deleting default env → first remaining becomes default.
def test_delete_default_reassigns(self):
mgr = _build_manager(MagicMock())
mgr.config = _make_config([_make_env("env1", is_default=True), _make_env("env2")])
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
mgr.delete_environment("env1")
assert mgr.config.environments[0].is_default is True
# #endregion test_delete_default_reassigns
# #region test_delete_updates_default_setting [C:2] [TYPE Function]
# @BRIEF Deleting env referenced in settings.default_environment_id updates it.
def test_delete_updates_default_setting(self):
mgr = _build_manager(MagicMock())
settings = GlobalSettings(default_environment_id="env1")
mgr.config = _make_config([_make_env("env1", is_default=True), _make_env("env2")], settings=settings)
with patch(f"{_MODULE}.belief_scope"), patch.object(mgr, "save"):
mgr.delete_environment("env1")
assert mgr.config.settings.default_environment_id != "env1"
# #endregion test_delete_updates_default_setting
#endregion Test.Core.ConfigManager

View File

@@ -0,0 +1,563 @@
# #region Test.Core.Database [C:3] [TYPE Module] [SEMANTICS test,database,core]
# @BRIEF Verify DatabaseModule — engine creation, session lifecycle, additive migrations, init_db.
# @RELATION BINDS_TO -> [DatabaseModule]
# @TEST_EDGE: table_not_exists -> migration returns early
# @TEST_EDGE: columns_present -> no ALTER executed
# @TEST_EDGE: migration_failure -> exception handled per function contract
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import contextlib
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "src"))
from src.core.database import (
BASE_DIR, _build_engine, init_db, get_db, get_tasks_db, get_auth_db,
_ensure_user_dashboard_preferences_columns,
_ensure_user_dashboard_preferences_health_columns,
_ensure_llm_validation_results_columns,
_ensure_git_server_configs_columns,
_ensure_auth_users_columns,
_ensure_roles_is_admin_column,
_ensure_filter_source_enum_values,
_ensure_translation_jobs_columns,
_ensure_dataset_review_session_columns,
_ensure_translation_schedules_columns,
_ensure_dictionary_entries_columns,
)
class _FakeCM:
def __init__(self, conn): self._conn = conn
def __enter__(self): return self._conn
def __exit__(self, *a): return False
_NOOP = contextlib.contextmanager(lambda id, msg="": (yield))
_P = "src.core.database"
def _insp(tables=None, cols=None):
m = MagicMock()
m.get_table_names.return_value = tables or []
m.get_columns.return_value = [{"name": c} for c in (cols or [])]
return m
def _eng():
e, c = MagicMock(), MagicMock()
e.begin.return_value = _FakeCM(c)
e.connect.return_value = _FakeCM(c)
return e, c
# ═══════════════════════════════════════════════════════════════════════════
# Engine creation & constants
# ═══════════════════════════════════════════════════════════════════════════
# #region test_build_engine [C:2] [TYPE Function]
# @BRIEF SQLite gets check_same_thread; Postgres gets pool_pre_ping.
@patch(f"{_P}.create_engine")
def test_build_engine(mock_ce):
with patch(f"{_P}.belief_scope", _NOOP):
_build_engine("sqlite:///t.db")
_build_engine("postgresql://u:p@h/d")
assert mock_ce.call_args_list[0].kwargs == {"connect_args": {"check_same_thread": False}}
assert mock_ce.call_args_list[1].kwargs == {"pool_pre_ping": True}
# #endregion test_build_engine
# #region test_base_dir [C:2] [TYPE Function]
# @BRIEF BASE_DIR resolves to backend root.
def test_base_dir():
assert BASE_DIR == Path(__file__).resolve().parent.parent.parent
# #endregion test_base_dir
# ═══════════════════════════════════════════════════════════════════════════
# Parametrized migration tests
# ═══════════════════════════════════════════════════════════════════════════
# #region test_migration_table_missing [C:2] [TYPE Function]
# @BRIEF All migration functions return early when table absent.
@pytest.mark.parametrize("fn,table", [
(_ensure_user_dashboard_preferences_columns, "user_dashboard_preferences"),
(_ensure_user_dashboard_preferences_health_columns, "user_dashboard_preferences"),
(_ensure_llm_validation_results_columns, "llm_validation_results"),
(_ensure_git_server_configs_columns, "git_server_configs"),
(_ensure_auth_users_columns, "users"),
(_ensure_roles_is_admin_column, "roles"),
(_ensure_translation_jobs_columns, "translation_jobs"),
(_ensure_translation_schedules_columns, "translation_schedules"),
(_ensure_dictionary_entries_columns, "dictionary_entries"),
])
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_migration_table_missing(mock_inspect, fn, table):
mock_inspect.return_value = _insp(tables=[])
eng = MagicMock()
fn(eng)
eng.begin.assert_not_called()
# #endregion test_migration_table_missing
# #region test_pref_columns_add [C:2] [TYPE Function]
# @BRIEF Adds 6 missing columns to user_dashboard_preferences.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_pref_columns_add(mock_inspect, _):
mock_inspect.return_value = _insp(["user_dashboard_preferences"], ["git_username"])
eng, conn = _eng()
_ensure_user_dashboard_preferences_columns(eng)
assert conn.execute.call_count == 6
# #endregion test_pref_columns_add
# #region test_pref_columns_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when all 7 pref columns exist.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_pref_columns_all_present(mock_inspect):
cols = ["git_username","git_email","git_personal_access_token_encrypted",
"start_page","auto_open_task_drawer","dashboards_table_density","show_only_slug_dashboards"]
mock_inspect.return_value = _insp(["user_dashboard_preferences"], cols)
eng = MagicMock()
_ensure_user_dashboard_preferences_columns(eng)
eng.begin.assert_not_called()
# #endregion test_pref_columns_all_present
# #region test_pref_failure_swallowed [C:2] [TYPE Function]
# @BRIEF Exception during pref migration is logged, not re-raised.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("boom"))
@patch(f"{_P}.inspect")
def test_pref_failure_swallowed(mock_inspect, _):
mock_inspect.return_value = _insp(["user_dashboard_preferences"], [])
eng, _ = _eng()
_ensure_user_dashboard_preferences_columns(eng) # must not raise
# #endregion test_pref_failure_swallowed
# #region test_health_columns [C:2] [TYPE Function]
# @BRIEF Health columns: add 3 when missing, skip when present, swallow errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_health_columns(mock_inspect, _):
mock_inspect.return_value = _insp(["user_dashboard_preferences"], [])
eng, conn = _eng()
_ensure_user_dashboard_preferences_health_columns(eng)
assert conn.execute.call_count == 3
# #endregion test_health_columns
# #region test_health_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when all health columns exist.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_health_all_present(mock_inspect):
mock_inspect.return_value = _insp(["user_dashboard_preferences"],
["telegram_id","email_address","notify_on_fail"])
eng = MagicMock()
_ensure_user_dashboard_preferences_health_columns(eng)
eng.begin.assert_not_called()
# #endregion test_health_all_present
# #region test_health_failure_swallowed [C:2] [TYPE Function]
# @BRIEF Health migration exception is logged, not re-raised.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("boom"))
@patch(f"{_P}.inspect")
def test_health_failure_swallowed(mock_inspect, _):
mock_inspect.return_value = _insp(["user_dashboard_preferences"], [])
eng, _ = _eng()
_ensure_user_dashboard_preferences_health_columns(eng)
# #endregion test_health_failure_swallowed
# #region test_llm_columns [C:2] [TYPE Function]
# @BRIEF LLM validation: add 2 columns, skip when present, swallow errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_llm_columns(mock_inspect, _):
mock_inspect.return_value = _insp(["llm_validation_results"], [])
eng, conn = _eng()
_ensure_llm_validation_results_columns(eng)
assert conn.execute.call_count == 2
# #endregion test_llm_columns
# #region test_llm_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when task_id and environment_id exist.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_llm_all_present(mock_inspect):
mock_inspect.return_value = _insp(["llm_validation_results"], ["task_id","environment_id"])
eng = MagicMock()
_ensure_llm_validation_results_columns(eng)
eng.begin.assert_not_called()
# #endregion test_llm_all_present
# #region test_llm_failure_swallowed [C:2] [TYPE Function]
# @BRIEF LLM migration exception is logged, not re-raised.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("boom"))
@patch(f"{_P}.inspect")
def test_llm_failure_swallowed(mock_inspect, _):
mock_inspect.return_value = _insp(["llm_validation_results"], [])
eng, _ = _eng()
_ensure_llm_validation_results_columns(eng)
# #endregion test_llm_failure_swallowed
# #region test_git_columns [C:2] [TYPE Function]
# @BRIEF Git configs: add default_branch, skip when present, swallow errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_git_columns(mock_inspect, _):
mock_inspect.return_value = _insp(["git_server_configs"], [])
eng, conn = _eng()
_ensure_git_server_configs_columns(eng)
assert conn.execute.call_count == 1
# #endregion test_git_columns
# #region test_git_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when default_branch exists.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_git_all_present(mock_inspect):
mock_inspect.return_value = _insp(["git_server_configs"], ["default_branch"])
eng = MagicMock()
_ensure_git_server_configs_columns(eng)
eng.begin.assert_not_called()
# #endregion test_git_all_present
# #region test_git_failure_swallowed [C:2] [TYPE Function]
# @BRIEF Git migration exception is logged, not re-raised.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("boom"))
@patch(f"{_P}.inspect")
def test_git_failure_swallowed(mock_inspect, _):
mock_inspect.return_value = _insp(["git_server_configs"], [])
eng, _ = _eng()
_ensure_git_server_configs_columns(eng)
# #endregion test_git_failure_swallowed
# #region test_auth_users [C:2] [TYPE Function]
# @BRIEF Auth users: add 2 columns, skip when present, re-raise errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_auth_users(mock_inspect, _):
mock_inspect.return_value = _insp(["users"], [])
eng, conn = _eng()
_ensure_auth_users_columns(eng)
assert conn.execute.call_count == 2
# #endregion test_auth_users
# #region test_auth_users_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when full_name and is_ad_user exist.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_auth_users_all_present(mock_inspect):
mock_inspect.return_value = _insp(["users"], ["full_name","is_ad_user"])
eng = MagicMock()
_ensure_auth_users_columns(eng)
eng.begin.assert_not_called()
# #endregion test_auth_users_all_present
# #region test_auth_users_failure_reraises [C:2] [TYPE Function]
# @BRIEF Auth users migration exception is re-raised.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("db error"))
@patch(f"{_P}.inspect")
def test_auth_users_failure_reraises(mock_inspect, _):
mock_inspect.return_value = _insp(["users"], [])
eng, _ = _eng()
with pytest.raises(RuntimeError, match="db error"):
_ensure_auth_users_columns(eng)
# #endregion test_auth_users_failure_reraises
# #region test_roles [C:2] [TYPE Function]
# @BRIEF Roles: add is_admin + update Admin, skip when present, re-raise errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_roles(mock_inspect, _):
mock_inspect.return_value = _insp(["roles"], [])
eng, conn = _eng()
_ensure_roles_is_admin_column(eng)
assert conn.execute.call_count == 2 # ALTER + UPDATE
# #endregion test_roles
# #region test_roles_already_exists [C:2] [TYPE Function]
# @BRIEF No ALTER when is_admin present.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_roles_already_exists(mock_inspect):
mock_inspect.return_value = _insp(["roles"], ["is_admin"])
eng = MagicMock()
_ensure_roles_is_admin_column(eng)
eng.begin.assert_not_called()
# #endregion test_roles_already_exists
# #region test_roles_failure_reraises [C:2] [TYPE Function]
# @BRIEF Roles migration exception is re-raised.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("fail"))
@patch(f"{_P}.inspect")
def test_roles_failure_reraises(mock_inspect, _):
mock_inspect.return_value = _insp(["roles"], [])
eng, _ = _eng()
with pytest.raises(RuntimeError, match="fail"):
_ensure_roles_is_admin_column(eng)
# #endregion test_roles_failure_reraises
# #region test_filter_enum [C:2] [TYPE Function]
# @BRIEF FilterSource enum: skip if type missing, add missing values, swallow errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
def test_filter_enum(_):
eng, conn = _eng()
conn.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
_ensure_filter_source_enum_values(eng) # type missing → skip
# #endregion test_filter_enum
# #region test_filter_enum_adds [C:2] [TYPE Function]
# @BRIEF Adds missing enum values when type exists.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
def test_filter_enum_adds(_):
eng, conn = _eng()
type_res = MagicMock(); type_res.fetchone.return_value = ("filtersource",)
enum_res = MagicMock(); enum_res.fetchall.return_value = [("SUPERSET_PERMALINK",)]
conn.execute.side_effect = [type_res, enum_res, MagicMock()]
_ensure_filter_source_enum_values(eng)
conn.commit.assert_called_once()
# #endregion test_filter_enum_adds
# #region test_filter_enum_complete [C:2] [TYPE Function]
# @BRIEF No ALTER TYPE when all enum values already present.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
def test_filter_enum_complete(_):
eng, conn = _eng()
type_res = MagicMock(); type_res.fetchone.return_value = ("filtersource",)
enum_res = MagicMock(); enum_res.fetchall.return_value = [
("SUPERSET_PERMALINK",), ("SUPERSET_NATIVE_FILTERS_KEY",)]
conn.execute.side_effect = [type_res, enum_res]
_ensure_filter_source_enum_values(eng)
conn.commit.assert_not_called()
# #endregion test_filter_enum_complete
# #region test_filter_enum_failure [C:2] [TYPE Function]
# @BRIEF Enum migration exception is swallowed.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("pg error"))
def test_filter_enum_failure(_):
eng, _ = _eng()
_ensure_filter_source_enum_values(eng)
# #endregion test_filter_enum_failure
# #region test_translation_jobs [C:2] [TYPE Function]
# @BRIEF Translation jobs: add 6 columns, skip when present, env_id re-raises.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_translation_jobs(mock_inspect, _):
mock_inspect.return_value = _insp(["translation_jobs"], [])
eng, conn = _eng()
_ensure_translation_jobs_columns(eng)
assert conn.execute.call_count == 6
# #endregion test_translation_jobs
# #region test_tj_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when all 6 translation_jobs columns exist.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_tj_all_present(mock_inspect):
cols = ["environment_id","target_database_id","target_language_column",
"target_source_column","target_source_language_column","disable_reasoning"]
mock_inspect.return_value = _insp(["translation_jobs"], cols)
eng = MagicMock()
_ensure_translation_jobs_columns(eng)
eng.begin.assert_not_called()
# #endregion test_tj_all_present
# #region test_tj_env_failure_reraises [C:2] [TYPE Function]
# @BRIEF environment_id failure re-raises.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_tj_env_failure_reraises(mock_inspect):
mock_inspect.return_value = _insp(["translation_jobs"], [])
eng = MagicMock()
eng.begin.side_effect = RuntimeError("conn fail")
with pytest.raises(RuntimeError, match="conn fail"):
_ensure_translation_jobs_columns(eng)
# #endregion test_tj_env_failure_reraises
# #region test_tj_partial [C:2] [TYPE Function]
# @BRIEF Only adds missing columns (4 of 6).
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_tj_partial(mock_inspect, _):
mock_inspect.return_value = _insp(["translation_jobs"],
["environment_id","target_database_id"])
eng, conn = _eng()
_ensure_translation_jobs_columns(eng)
assert conn.execute.call_count == 4
# #endregion test_tj_partial
# #region test_tj_col_failure_swallowed [C:2] [TYPE Function]
# @BRIEF Non-env column failures are swallowed.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_tj_col_failure_swallowed(mock_inspect, _):
mock_inspect.return_value = _insp(["translation_jobs"], ["environment_id"])
eng, _ = _eng()
eng.begin.side_effect = RuntimeError("fail")
_ensure_translation_jobs_columns(eng) # must not raise
# #endregion test_tj_col_failure_swallowed
# #region test_dataset_review [C:2] [TYPE Function]
# @BRIEF Dataset review: add 2 columns across 2 tables, re-raise errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_dataset_review(mock_inspect, _):
mock_inspect.return_value = _insp(
["dataset_review_sessions","imported_filters"], [])
eng, conn = _eng()
_ensure_dataset_review_session_columns(eng)
assert conn.execute.call_count == 2
# #endregion test_dataset_review
# #region test_dr_partial_tables [C:2] [TYPE Function]
# @BRIEF Skips missing tables, migrates existing ones.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_dr_partial_tables(mock_inspect, _):
# Only dataset_review_sessions exists, imported_filters does not
mock_inspect.return_value = _insp(["dataset_review_sessions"], [])
eng, conn = _eng()
_ensure_dataset_review_session_columns(eng)
assert conn.execute.call_count == 1 # only version column for sessions
# #endregion test_dr_partial_tables
# #region test_dr_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when version and raw_value_masked exist.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_dr_all_present(mock_inspect):
mock_inspect.return_value = _insp(
["dataset_review_sessions","imported_filters"], ["version","raw_value_masked"])
eng = MagicMock()
_ensure_dataset_review_session_columns(eng)
eng.begin.assert_not_called()
# #endregion test_dr_all_present
# #region test_dr_failure_reraises [C:2] [TYPE Function]
# @BRIEF Dataset review migration exception is re-raised.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("fail"))
@patch(f"{_P}.inspect")
def test_dr_failure_reraises(mock_inspect, _):
mock_inspect.return_value = _insp(
["dataset_review_sessions","imported_filters"], [])
eng, _ = _eng()
with pytest.raises(RuntimeError, match="fail"):
_ensure_dataset_review_session_columns(eng)
# #endregion test_dr_failure_reraises
# #region test_schedules [C:2] [TYPE Function]
# @BRIEF Translation schedules: add execution_mode, skip when present, swallow errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_schedules(mock_inspect, _):
mock_inspect.return_value = _insp(["translation_schedules"], [])
eng, conn = _eng()
_ensure_translation_schedules_columns(eng)
assert conn.execute.call_count == 1
# #endregion test_schedules
# #region test_schedules_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when execution_mode exists.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_schedules_all_present(mock_inspect):
mock_inspect.return_value = _insp(["translation_schedules"], ["execution_mode"])
eng = MagicMock()
_ensure_translation_schedules_columns(eng)
eng.begin.assert_not_called()
# #endregion test_schedules_all_present
# #region test_schedules_failure [C:2] [TYPE Function]
# @BRIEF Schedules migration exception is swallowed.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("err"))
@patch(f"{_P}.inspect")
def test_schedules_failure(mock_inspect, _):
mock_inspect.return_value = _insp(["translation_schedules"], [])
eng, _ = _eng()
_ensure_translation_schedules_columns(eng)
# #endregion test_schedules_failure
# #region test_dictionary [C:2] [TYPE Function]
# @BRIEF Dictionary entries: add 3 columns, skip when present, swallow errors.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=lambda s: s)
@patch(f"{_P}.inspect")
def test_dictionary(mock_inspect, _):
mock_inspect.return_value = _insp(["dictionary_entries"], [])
eng, conn = _eng()
_ensure_dictionary_entries_columns(eng)
assert conn.execute.call_count == 3
# #endregion test_dictionary
# #region test_dict_all_present [C:2] [TYPE Function]
# @BRIEF No ALTER when all 3 origin columns exist.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.inspect")
def test_dict_all_present(mock_inspect):
mock_inspect.return_value = _insp(["dictionary_entries"],
["origin_run_id","origin_row_key","origin_user_id"])
eng = MagicMock()
_ensure_dictionary_entries_columns(eng)
eng.begin.assert_not_called()
# #endregion test_dict_all_present
# #region test_dict_failure [C:2] [TYPE Function]
# @BRIEF Dictionary migration exception is swallowed.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.text", side_effect=RuntimeError("err"))
@patch(f"{_P}.inspect")
def test_dict_failure(mock_inspect, _):
mock_inspect.return_value = _insp(["dictionary_entries"], [])
eng, _ = _eng()
_ensure_dictionary_entries_columns(eng)
# #endregion test_dict_failure
# ═══════════════════════════════════════════════════════════════════════════
# init_db & session generators
# ═══════════════════════════════════════════════════════════════════════════
# #region test_init_db [C:2] [TYPE Function]
# @BRIEF Calls create_all on all three engines.
@patch(f"{_P}.belief_scope", _NOOP)
@patch(f"{_P}.Base")
def test_init_db(mock_base):
mock_meta = MagicMock()
mock_base.metadata = mock_meta
init_db()
assert mock_meta.create_all.call_count == 3
# #endregion test_init_db
# #region test_session_generators [C:2] [TYPE Function]
# @BRIEF All session generators yield session and close on exit.
@pytest.mark.parametrize("gen_fn,factory_name", [
(get_db, "SessionLocal"), (get_tasks_db, "TasksSessionLocal"), (get_auth_db, "AuthSessionLocal"),
])
@patch(f"{_P}.belief_scope", _NOOP)
def test_session_generators(gen_fn, factory_name):
mock_sess = MagicMock()
with patch(f"{_P}.{factory_name}", return_value=mock_sess):
gen = gen_fn()
assert next(gen) is mock_sess
try: gen.send(None)
except StopIteration: pass
mock_sess.close.assert_called_once()
# #endregion test_session_generators
# #endregion Test.Core.Database

View File

@@ -0,0 +1,928 @@
# #region Test.DbExecutor [C:3] [TYPE Module] [SEMANTICS test,db,executor]
# @BRIEF Verify DbExecutor contracts — execute_sql routing, dialect executors, fetch_schema, pool caching.
# @RELATION BINDS_TO -> [Core.DbExecutor]
# @TEST_EDGE: connection_not_found -> execute_sql returns error result
# @TEST_EDGE: unsupported_dialect -> execute_sql returns error result
# @TEST_EDGE: driver_import_failure -> each dialect returns error when driver missing
# @TEST_EDGE: exception_during_execution -> execute_sql catches and returns error with timing
# @TEST_EDGE: pool_reuse -> second call reuses cached pool/client
# @TEST_EDGE: pool_invalidation -> config change (updated_at) triggers pool recreation
# @TEST_EDGE: pg_status_parsing -> various asyncpg status strings parsed correctly
# @TEST_EDGE: ch_non_int_result -> clickhouse non-int result yields rows_affected=0
# @TEST_EDGE: fetch_schema_unknown_dialect -> returns None
# @TEST_EDGE: fetch_schema_connection_not_found -> returns None
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from types import ModuleType
from src.core.db_executor import DbExecutor, DbExecutionResult, DbSchemaColumn
# ---------------------------------------------------------------------------
# Helpers (C1 — anchor pair only)
# ---------------------------------------------------------------------------
# #region _make_conn
def _make_conn(dialect="postgresql", conn_id="conn-1", updated_at="2025-01-01T00:00:00"):
c = MagicMock()
c.id = conn_id
c.dialect = dialect
c.host = "localhost"
c.port = 5432
c.database = "testdb"
c.username = "user"
c.password = "pass"
c.pool_size = 5
c.updated_at = updated_at
return c
# #endregion _make_conn
# #region _make_service
def _make_service(conn=None):
svc = MagicMock()
svc.get_connection.return_value = conn
return svc
# #endregion _make_service
# ===========================================================================
# execute_sql — routing & error paths
# ===========================================================================
class TestExecuteSqlRouting:
"""Verify execute_sql dispatches to correct dialect handler and handles errors."""
# #region test_connection_not_found [C:2] [TYPE Function]
# @BRIEF execute_sql returns failure when connection_id is unknown.
def test_connection_not_found(self):
svc = _make_service(conn=None)
executor = DbExecutor(svc)
result = executor.execute_sql("nonexistent", "SELECT 1")
assert result.success is False
assert "not found" in result.error
# #endregion test_connection_not_found
# #region test_unsupported_dialect [C:2] [TYPE Function]
# @BRIEF execute_sql returns failure for an unsupported dialect.
def test_unsupported_dialect(self):
conn = _make_conn(dialect="oracle")
svc = _make_service(conn)
executor = DbExecutor(svc)
result = executor.execute_sql(conn.id, "SELECT 1")
assert result.success is False
assert "Unsupported dialect" in result.error
# #endregion test_unsupported_dialect
# #region test_exception_during_execution [C:2] [TYPE Function]
# @BRIEF execute_sql catches driver exceptions and returns error with timing.
def test_exception_during_execution(self):
conn = _make_conn(dialect="clickhouse")
svc = _make_service(conn)
executor = DbExecutor(svc)
with patch("src.core.db_executor.clickhouse_connect", create=True) as mock_mod:
# Force an ImportError to be caught inside _execute_ch's try block
# by making clickhouse_connect.get_client raise
pass
# Simpler: patch the entire _execute_ch to raise
with patch.object(executor, "_execute_ch", side_effect=RuntimeError("boom")):
result = executor.execute_sql(conn.id, "INSERT INTO t VALUES (1)")
assert result.success is False
assert "boom" in result.error
assert result.execution_time_ms >= 0
# #endregion test_exception_during_execution
# ===========================================================================
# _execute_pg — PostgreSQL via asyncpg
# ===========================================================================
class TestExecutePg:
"""Verify PostgreSQL execution: pool creation, status parsing, pool reuse."""
# #region test_pg_success_insert [C:2] [TYPE Function]
# @BRIEF asyncpg returns 'INSERT 0 5' → rows_affected=5, success=True.
def test_pg_success_insert(self):
conn = _make_conn(dialect="postgresql")
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_pg_conn = AsyncMock()
mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 5")
mock_pool_ctx = AsyncMock()
mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn)
mock_pool_ctx.__aexit__ = AsyncMock(return_value=False)
mock_pool = MagicMock()
mock_pool.acquire.return_value = mock_pool_ctx
mock_asyncpg = MagicMock()
mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}):
result = executor._execute_pg(conn, "INSERT INTO t VALUES (1)", 0.0)
assert result.success is True
assert result.rows_affected == 5
assert result.execution_time_ms >= 0
# #endregion test_pg_success_insert
# #region test_pg_pool_reuse [C:2] [TYPE Function]
# @BRIEF Second call with same config reuses cached pool (create_pool called once).
def test_pg_pool_reuse(self):
conn = _make_conn(dialect="postgresql")
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_pg_conn = AsyncMock()
mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 1")
mock_pool_ctx = AsyncMock()
mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn)
mock_pool_ctx.__aexit__ = AsyncMock(return_value=False)
mock_pool = MagicMock()
mock_pool.acquire.return_value = mock_pool_ctx
mock_asyncpg = MagicMock()
mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}):
executor._execute_pg(conn, "INSERT 1", 0.0)
executor._execute_pg(conn, "INSERT 2", 0.0)
assert mock_asyncpg.create_pool.call_count == 1
# #endregion test_pg_pool_reuse
# #region test_pg_pool_invalidation [C:2] [TYPE Function]
# @BRIEF Config change (updated_at) triggers pool recreation.
def test_pg_pool_invalidation(self):
conn = _make_conn(dialect="postgresql", updated_at="v1")
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_pg_conn = AsyncMock()
mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 1")
mock_pool_ctx = AsyncMock()
mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn)
mock_pool_ctx.__aexit__ = AsyncMock(return_value=False)
mock_pool = MagicMock()
mock_pool.acquire.return_value = mock_pool_ctx
mock_asyncpg = MagicMock()
mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}):
executor._execute_pg(conn, "INSERT 1", 0.0)
# Simulate config update
conn.updated_at = "v2"
executor._execute_pg(conn, "INSERT 2", 0.0)
assert mock_asyncpg.create_pool.call_count == 2
# #endregion test_pg_pool_invalidation
# #region test_pg_status_empty [C:2] [TYPE Function]
# @BRIEF asyncpg returns empty status string → rows_affected=0.
def test_pg_status_empty(self):
conn = _make_conn(dialect="postgresql")
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_pg_conn = AsyncMock()
mock_pg_conn.execute = AsyncMock(return_value="")
mock_pool_ctx = AsyncMock()
mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn)
mock_pool_ctx.__aexit__ = AsyncMock(return_value=False)
mock_pool = MagicMock()
mock_pool.acquire.return_value = mock_pool_ctx
mock_asyncpg = MagicMock()
mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}):
result = executor._execute_pg(conn, "CREATE TABLE t()", 0.0)
assert result.success is True
assert result.rows_affected == 0
# #endregion test_pg_status_empty
# #region test_pg_status_non_numeric [C:2] [TYPE Function]
# @BRIEF asyncpg returns non-numeric status → rows_affected=0 (ValueError caught).
def test_pg_status_non_numeric(self):
conn = _make_conn(dialect="postgresql")
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_pg_conn = AsyncMock()
mock_pg_conn.execute = AsyncMock(return_value="STATEMENT")
mock_pool_ctx = AsyncMock()
mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn)
mock_pool_ctx.__aexit__ = AsyncMock(return_value=False)
mock_pool = MagicMock()
mock_pool.acquire.return_value = mock_pool_ctx
mock_asyncpg = MagicMock()
mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}):
result = executor._execute_pg(conn, "BEGIN", 0.0)
assert result.success is True
assert result.rows_affected == 0
# #endregion test_pg_status_non_numeric
# #region test_pg_status_single_part [C:2] [TYPE Function]
# @BRIEF asyncpg returns single-word status → rows_affected=0 (len(parts) < 2).
def test_pg_status_single_part(self):
conn = _make_conn(dialect="postgresql")
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_pg_conn = AsyncMock()
mock_pg_conn.execute = AsyncMock(return_value="COMMIT")
mock_pool_ctx = AsyncMock()
mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn)
mock_pool_ctx.__aexit__ = AsyncMock(return_value=False)
mock_pool = MagicMock()
mock_pool.acquire.return_value = mock_pool_ctx
mock_asyncpg = MagicMock()
mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}):
result = executor._execute_pg(conn, "COMMIT", 0.0)
assert result.success is True
assert result.rows_affected == 0
# #endregion test_pg_status_single_part
# #region test_pg_status_non_numeric_last_part [C:2] [TYPE Function]
# @BRIEF asyncpg returns multi-part status with non-numeric tail → ValueError caught, rows=0.
def test_pg_status_non_numeric_last_part(self):
conn = _make_conn(dialect="postgresql")
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_pg_conn = AsyncMock()
mock_pg_conn.execute = AsyncMock(return_value="INSERT 0 abc")
mock_pool_ctx = AsyncMock()
mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn)
mock_pool_ctx.__aexit__ = AsyncMock(return_value=False)
mock_pool = MagicMock()
mock_pool.acquire.return_value = mock_pool_ctx
mock_asyncpg = MagicMock()
mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}):
result = executor._execute_pg(conn, "INSERT INTO t VALUES (1)", 0.0)
assert result.success is True
assert result.rows_affected == 0
# #endregion test_pg_status_non_numeric_last_part
# #region test_pg_import_error [C:2] [TYPE Function]
# @BRIEF Missing asyncpg package → returns error result.
def test_pg_import_error(self):
conn = _make_conn(dialect="postgresql")
svc = _make_service(conn)
executor = DbExecutor(svc)
# Simulate ImportError by making asyncpg unimportable
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "asyncpg":
raise ImportError("No module named 'asyncpg'")
return real_import(name, *args, **kwargs)
with patch.object(builtins, "__import__", side_effect=fake_import):
result = executor._execute_pg(conn, "SELECT 1", 0.0)
assert result.success is False
assert "asyncpg" in result.error
# #endregion test_pg_import_error
# ===========================================================================
# _execute_ch — ClickHouse via clickhouse-connect
# ===========================================================================
class TestExecuteCh:
"""Verify ClickHouse execution: client creation, command result, pool reuse."""
# #region test_ch_success_int_result [C:2] [TYPE Function]
# @BRIEF clickhouse command returns int → rows_affected=int, success=True.
def test_ch_success_int_result(self):
conn = _make_conn(dialect="clickhouse", conn_id="ch-1")
# clickhouse default port
conn.port = 8123
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_client = MagicMock()
mock_client.command.return_value = 42
mock_ch_mod = MagicMock()
mock_ch_mod.get_client.return_value = mock_client
with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}):
result = executor._execute_ch(conn, "INSERT INTO t VALUES (1)", 0.0)
assert result.success is True
assert result.rows_affected == 42
assert result.execution_time_ms >= 0
# #endregion test_ch_success_int_result
# #region test_ch_non_int_result [C:2] [TYPE Function]
# @BRIEF clickhouse command returns non-int → rows_affected=0.
def test_ch_non_int_result(self):
conn = _make_conn(dialect="clickhouse", conn_id="ch-2")
conn.port = 8123
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_client = MagicMock()
mock_client.command.return_value = "OK"
mock_ch_mod = MagicMock()
mock_ch_mod.get_client.return_value = mock_client
with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}):
result = executor._execute_ch(conn, "CREATE TABLE t()", 0.0)
assert result.success is True
assert result.rows_affected == 0
# #endregion test_ch_non_int_result
# #region test_ch_pool_reuse [C:2] [TYPE Function]
# @BRIEF Second call reuses cached client (get_client called once).
def test_ch_pool_reuse(self):
conn = _make_conn(dialect="clickhouse", conn_id="ch-3")
conn.port = 8123
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_client = MagicMock()
mock_client.command.return_value = 1
mock_ch_mod = MagicMock()
mock_ch_mod.get_client.return_value = mock_client
with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}):
executor._execute_ch(conn, "INSERT 1", 0.0)
executor._execute_ch(conn, "INSERT 2", 0.0)
assert mock_ch_mod.get_client.call_count == 1
# #endregion test_ch_pool_reuse
# #region test_ch_import_error [C:2] [TYPE Function]
# @BRIEF Missing clickhouse-connect → returns error result.
def test_ch_import_error(self):
conn = _make_conn(dialect="clickhouse", conn_id="ch-4")
conn.port = 8123
svc = _make_service(conn)
executor = DbExecutor(svc)
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "clickhouse_connect":
raise ImportError("No module named 'clickhouse_connect'")
return real_import(name, *args, **kwargs)
with patch.object(builtins, "__import__", side_effect=fake_import):
result = executor._execute_ch(conn, "SELECT 1", 0.0)
assert result.success is False
assert "clickhouse-connect" in result.error
# #endregion test_ch_import_error
# ===========================================================================
# _execute_mysql — MySQL via pymysql
# ===========================================================================
class TestExecuteMysql:
"""Verify MySQL execution: connection creation, cursor execution, pool reuse."""
# #region test_mysql_success [C:2] [TYPE Function]
# @BRIEF pymysql cursor.execute returns row count → success=True.
def test_mysql_success(self):
conn = _make_conn(dialect="mysql", conn_id="my-1")
conn.port = 3306
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_cursor = MagicMock()
mock_cursor.execute.return_value = 3
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_db = MagicMock()
mock_db.cursor.return_value = mock_cursor
mock_pymysql = MagicMock()
mock_pymysql.connect.return_value = mock_db
with patch.dict(sys.modules, {"pymysql": mock_pymysql}):
result = executor._execute_mysql(conn, "INSERT INTO t VALUES (1)", 0.0)
assert result.success is True
assert result.rows_affected == 3
assert result.execution_time_ms >= 0
# #endregion test_mysql_success
# #region test_mysql_pool_reuse [C:2] [TYPE Function]
# @BRIEF Second call reuses cached connection (connect called once).
def test_mysql_pool_reuse(self):
conn = _make_conn(dialect="mysql", conn_id="my-2")
conn.port = 3306
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_cursor = MagicMock()
mock_cursor.execute.return_value = 1
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_db = MagicMock()
mock_db.cursor.return_value = mock_cursor
mock_pymysql = MagicMock()
mock_pymysql.connect.return_value = mock_db
with patch.dict(sys.modules, {"pymysql": mock_pymysql}):
executor._execute_mysql(conn, "INSERT 1", 0.0)
executor._execute_mysql(conn, "INSERT 2", 0.0)
assert mock_pymysql.connect.call_count == 1
# #endregion test_mysql_pool_reuse
# #region test_mysql_import_error [C:2] [TYPE Function]
# @BRIEF Missing pymysql → returns error result.
def test_mysql_import_error(self):
conn = _make_conn(dialect="mysql", conn_id="my-3")
conn.port = 3306
svc = _make_service(conn)
executor = DbExecutor(svc)
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "pymysql":
raise ImportError("No module named 'pymysql'")
return real_import(name, *args, **kwargs)
with patch.object(builtins, "__import__", side_effect=fake_import):
result = executor._execute_mysql(conn, "SELECT 1", 0.0)
assert result.success is False
assert "pymysql" in result.error
# #endregion test_mysql_import_error
# ===========================================================================
# fetch_schema — dialect routing & schema fetching
# ===========================================================================
class TestFetchSchema:
"""Verify fetch_schema dispatches to correct dialect fetcher and handles errors."""
# #region test_fetch_schema_connection_not_found [C:2] [TYPE Function]
# @BRIEF fetch_schema returns None when connection_id is unknown.
def test_fetch_schema_connection_not_found(self):
executor = DbExecutor(MagicMock())
mock_svc = MagicMock()
mock_svc.get_connection.return_value = None
with patch("src.core.connection_service.ConnectionService", return_value=mock_svc):
result = executor.fetch_schema("nonexistent", "public", "users", MagicMock())
assert result is None
# #endregion test_fetch_schema_connection_not_found
# #region test_fetch_schema_clickhouse [C:2] [TYPE Function]
# @BRIEF fetch_schema routes to _fetch_ch for clickhouse dialect.
def test_fetch_schema_clickhouse(self):
conn = _make_conn(dialect="clickhouse")
executor = DbExecutor(MagicMock())
mock_svc = MagicMock()
mock_svc.get_connection.return_value = conn
expected = [DbSchemaColumn(name="id", data_type="UInt64")]
with patch("src.core.connection_service.ConnectionService", return_value=mock_svc), \
patch.object(executor, "_fetch_ch", return_value=expected) as mock_fetch:
result = executor.fetch_schema("ch-1", "default", "events", MagicMock())
assert result == expected
mock_fetch.assert_called_once()
# Verify SQL contains system.columns
call_sql = mock_fetch.call_args[0][1]
assert "system.columns" in call_sql
# #endregion test_fetch_schema_clickhouse
# #region test_fetch_schema_postgresql [C:2] [TYPE Function]
# @BRIEF fetch_schema routes to _fetch_pg for postgresql dialect.
def test_fetch_schema_postgresql(self):
conn = _make_conn(dialect="postgresql")
executor = DbExecutor(MagicMock())
mock_svc = MagicMock()
mock_svc.get_connection.return_value = conn
expected = [DbSchemaColumn(name="id", data_type="integer")]
with patch("src.core.connection_service.ConnectionService", return_value=mock_svc), \
patch.object(executor, "_fetch_pg", return_value=expected) as mock_fetch:
result = executor.fetch_schema("pg-1", "public", "users", MagicMock())
assert result == expected
mock_fetch.assert_called_once()
call_sql = mock_fetch.call_args[0][1]
assert "information_schema.columns" in call_sql
# #endregion test_fetch_schema_postgresql
# #region test_fetch_schema_mysql [C:2] [TYPE Function]
# @BRIEF fetch_schema routes to _fetch_mysql for mysql dialect.
def test_fetch_schema_mysql(self):
conn = _make_conn(dialect="mysql")
executor = DbExecutor(MagicMock())
mock_svc = MagicMock()
mock_svc.get_connection.return_value = conn
expected = [DbSchemaColumn(name="id", data_type="int")]
with patch("src.core.connection_service.ConnectionService", return_value=mock_svc), \
patch.object(executor, "_fetch_mysql", return_value=expected) as mock_fetch:
result = executor.fetch_schema("my-1", "mydb", "users", MagicMock())
assert result == expected
mock_fetch.assert_called_once()
call_sql = mock_fetch.call_args[0][1]
assert "information_schema.columns" in call_sql
# #endregion test_fetch_schema_mysql
# #region test_fetch_schema_unsupported_dialect [C:2] [TYPE Function]
# @BRIEF fetch_schema returns None for unsupported dialect.
def test_fetch_schema_unsupported_dialect(self):
conn = _make_conn(dialect="oracle")
executor = DbExecutor(MagicMock())
mock_svc = MagicMock()
mock_svc.get_connection.return_value = conn
with patch("src.core.connection_service.ConnectionService", return_value=mock_svc):
result = executor.fetch_schema("ora-1", "dbo", "users", MagicMock())
assert result is None
# #endregion test_fetch_schema_unsupported_dialect
# #region test_fetch_schema_sql_injection_escaping [C:2] [TYPE Function]
# @BRIEF fetch_schema escapes single quotes in schema/table names.
def test_fetch_schema_sql_injection_escaping(self):
conn = _make_conn(dialect="postgresql")
executor = DbExecutor(MagicMock())
mock_svc = MagicMock()
mock_svc.get_connection.return_value = conn
with patch("src.core.connection_service.ConnectionService", return_value=mock_svc), \
patch.object(executor, "_fetch_pg", return_value=[]) as mock_fetch:
executor.fetch_schema("pg-1", "pub'lic", "us'ers", MagicMock())
call_sql = mock_fetch.call_args[0][1]
assert "pub''lic" in call_sql
assert "us''ers" in call_sql
# #endregion test_fetch_schema_sql_injection_escaping
# ===========================================================================
# _fetch_pg — PostgreSQL schema fetch
# ===========================================================================
class TestFetchPg:
"""Verify _fetch_pg: pool creation, row mapping, import error."""
# #region test_fetch_pg_success [C:2] [TYPE Function]
# @BRIEF asyncpg fetch returns rows → mapped to DbSchemaColumn list.
def test_fetch_pg_success(self):
conn = _make_conn(dialect="postgresql", conn_id="pg-f1")
svc = _make_service(conn)
executor = DbExecutor(svc)
fake_rows = [
{"name": "id", "type": "integer"},
{"name": "email", "type": "varchar"},
]
mock_pg_conn = AsyncMock()
mock_pg_conn.fetch = AsyncMock(return_value=fake_rows)
mock_pool_ctx = AsyncMock()
mock_pool_ctx.__aenter__ = AsyncMock(return_value=mock_pg_conn)
mock_pool_ctx.__aexit__ = AsyncMock(return_value=False)
mock_pool = MagicMock()
mock_pool.acquire.return_value = mock_pool_ctx
mock_asyncpg = MagicMock()
mock_asyncpg.create_pool = AsyncMock(return_value=mock_pool)
with patch.dict(sys.modules, {"asyncpg": mock_asyncpg}):
result = executor._fetch_pg(conn, "SELECT column_name, data_type FROM information_schema.columns")
assert result is not None
assert len(result) == 2
assert result[0].name == "id"
assert result[0].data_type == "integer"
assert result[1].name == "email"
assert result[1].data_type == "varchar"
# #endregion test_fetch_pg_success
# #region test_fetch_pg_import_error [C:2] [TYPE Function]
# @BRIEF Missing asyncpg → _fetch_pg returns None.
def test_fetch_pg_import_error(self):
conn = _make_conn(dialect="postgresql", conn_id="pg-f2")
svc = _make_service(conn)
executor = DbExecutor(svc)
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "asyncpg":
raise ImportError("No module named 'asyncpg'")
return real_import(name, *args, **kwargs)
with patch.object(builtins, "__import__", side_effect=fake_import):
result = executor._fetch_pg(conn, "SELECT 1")
assert result is None
# #endregion test_fetch_pg_import_error
# ===========================================================================
# _fetch_ch — ClickHouse schema fetch
# ===========================================================================
class TestFetchCh:
"""Verify _fetch_ch: client creation, row mapping, empty result, import error."""
# #region test_fetch_ch_success [C:2] [TYPE Function]
# @BRIEF clickhouse query returns rows → mapped to DbSchemaColumn list.
def test_fetch_ch_success(self):
conn = _make_conn(dialect="clickhouse", conn_id="ch-f1")
conn.port = 8123
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_query_result = MagicMock()
mock_query_result.result_rows = [
("id", "UInt64"),
("name", "String"),
]
mock_client = MagicMock()
mock_client.query.return_value = mock_query_result
mock_ch_mod = MagicMock()
mock_ch_mod.get_client.return_value = mock_client
with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}):
result = executor._fetch_ch(conn, "SELECT name, type FROM system.columns")
assert result is not None
assert len(result) == 2
assert result[0].name == "id"
assert result[0].data_type == "UInt64"
assert result[1].name == "name"
assert result[1].data_type == "String"
# #endregion test_fetch_ch_success
# #region test_fetch_ch_empty_rows [C:2] [TYPE Function]
# @BRIEF clickhouse query returns empty rows → returns empty list.
def test_fetch_ch_empty_rows(self):
conn = _make_conn(dialect="clickhouse", conn_id="ch-f2")
conn.port = 8123
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_query_result = MagicMock()
mock_query_result.result_rows = []
mock_client = MagicMock()
mock_client.query.return_value = mock_query_result
mock_ch_mod = MagicMock()
mock_ch_mod.get_client.return_value = mock_client
with patch.dict(sys.modules, {"clickhouse_connect": mock_ch_mod}):
result = executor._fetch_ch(conn, "SELECT name, type FROM system.columns")
assert result == []
# #endregion test_fetch_ch_empty_rows
# #region test_fetch_ch_import_error [C:2] [TYPE Function]
# @BRIEF Missing clickhouse-connect → _fetch_ch returns None.
def test_fetch_ch_import_error(self):
conn = _make_conn(dialect="clickhouse", conn_id="ch-f3")
conn.port = 8123
svc = _make_service(conn)
executor = DbExecutor(svc)
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "clickhouse_connect":
raise ImportError("No module named 'clickhouse_connect'")
return real_import(name, *args, **kwargs)
with patch.object(builtins, "__import__", side_effect=fake_import):
result = executor._fetch_ch(conn, "SELECT 1")
assert result is None
# #endregion test_fetch_ch_import_error
# ===========================================================================
# _fetch_mysql — MySQL schema fetch
# ===========================================================================
class TestFetchMysql:
"""Verify _fetch_mysql: connection creation, fetchall mapping, empty result, import error."""
# #region test_fetch_mysql_success [C:2] [TYPE Function]
# @BRIEF pymysql fetchall returns rows → mapped to DbSchemaColumn list.
def test_fetch_mysql_success(self):
conn = _make_conn(dialect="mysql", conn_id="my-f1")
conn.port = 3306
svc = _make_service(conn)
executor = DbExecutor(svc)
fake_rows = [
("id", "int"),
("name", "varchar(255)"),
]
mock_cursor = MagicMock()
mock_cursor.fetchall.return_value = fake_rows
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_db = MagicMock()
mock_db.cursor.return_value = mock_cursor
mock_pymysql = MagicMock()
mock_pymysql.connect.return_value = mock_db
with patch.dict(sys.modules, {"pymysql": mock_pymysql}):
result = executor._fetch_mysql(conn, "SELECT column_name, data_type FROM information_schema.columns")
assert result is not None
assert len(result) == 2
assert result[0].name == "id"
assert result[0].data_type == "int"
assert result[1].name == "name"
assert result[1].data_type == "varchar(255)"
# #endregion test_fetch_mysql_success
# #region test_fetch_mysql_empty_rows [C:2] [TYPE Function]
# @BRIEF pymysql fetchall returns empty → returns empty list.
def test_fetch_mysql_empty_rows(self):
conn = _make_conn(dialect="mysql", conn_id="my-f2")
conn.port = 3306
svc = _make_service(conn)
executor = DbExecutor(svc)
mock_cursor = MagicMock()
mock_cursor.fetchall.return_value = []
mock_cursor.__enter__ = MagicMock(return_value=mock_cursor)
mock_cursor.__exit__ = MagicMock(return_value=False)
mock_db = MagicMock()
mock_db.cursor.return_value = mock_cursor
mock_pymysql = MagicMock()
mock_pymysql.connect.return_value = mock_db
with patch.dict(sys.modules, {"pymysql": mock_pymysql}):
result = executor._fetch_mysql(conn, "SELECT column_name, data_type FROM information_schema.columns")
assert result == []
# #endregion test_fetch_mysql_empty_rows
# #region test_fetch_mysql_import_error [C:2] [TYPE Function]
# @BRIEF Missing pymysql → _fetch_mysql returns None.
def test_fetch_mysql_import_error(self):
conn = _make_conn(dialect="mysql", conn_id="my-f3")
conn.port = 3306
svc = _make_service(conn)
executor = DbExecutor(svc)
import builtins
real_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "pymysql":
raise ImportError("No module named 'pymysql'")
return real_import(name, *args, **kwargs)
with patch.object(builtins, "__import__", side_effect=fake_import):
result = executor._fetch_mysql(conn, "SELECT 1")
assert result is None
# #endregion test_fetch_mysql_import_error
# ===========================================================================
# execute_sql — full integration through dialect handlers
# ===========================================================================
class TestExecuteSqlDialects:
"""Verify execute_sql dispatches correctly to each dialect handler via mocked drivers."""
# #region test_execute_sql_postgresql [C:2] [TYPE Function]
# @BRIEF execute_sql routes postgresql to _execute_pg and returns its result.
def test_execute_sql_postgresql(self):
conn = _make_conn(dialect="postgresql")
svc = _make_service(conn)
executor = DbExecutor(svc)
expected = DbExecutionResult(success=True, rows_affected=10, execution_time_ms=50.0)
with patch.object(executor, "_execute_pg", return_value=expected) as mock_pg:
result = executor.execute_sql(conn.id, "INSERT INTO t VALUES (1)")
assert result.success is True
assert result.rows_affected == 10
mock_pg.assert_called_once_with(conn, "INSERT INTO t VALUES (1)", mock_pg.call_args[0][2])
# #endregion test_execute_sql_postgresql
# #region test_execute_sql_clickhouse [C:2] [TYPE Function]
# @BRIEF execute_sql routes clickhouse to _execute_ch and returns its result.
def test_execute_sql_clickhouse(self):
conn = _make_conn(dialect="clickhouse")
svc = _make_service(conn)
executor = DbExecutor(svc)
expected = DbExecutionResult(success=True, rows_affected=7, execution_time_ms=30.0)
with patch.object(executor, "_execute_ch", return_value=expected) as mock_ch:
result = executor.execute_sql(conn.id, "INSERT INTO t VALUES (1)")
assert result.success is True
assert result.rows_affected == 7
mock_ch.assert_called_once()
# #endregion test_execute_sql_clickhouse
# #region test_execute_sql_mysql [C:2] [TYPE Function]
# @BRIEF execute_sql routes mysql to _execute_mysql and returns its result.
def test_execute_sql_mysql(self):
conn = _make_conn(dialect="mysql")
svc = _make_service(conn)
executor = DbExecutor(svc)
expected = DbExecutionResult(success=True, rows_affected=3, execution_time_ms=20.0)
with patch.object(executor, "_execute_mysql", return_value=expected) as mock_my:
result = executor.execute_sql(conn.id, "INSERT INTO t VALUES (1)")
assert result.success is True
assert result.rows_affected == 3
mock_my.assert_called_once()
# #endregion test_execute_sql_mysql
# ===========================================================================
# Dataclass sanity
# ===========================================================================
class TestDataclasses:
"""Verify DbExecutionResult and DbSchemaColumn default values."""
# #region test_db_execution_result_defaults [C:2] [TYPE Function]
# @BRIEF DbExecutionResult defaults: rows_affected=0, execution_time_ms=0.0, error=None, db_version=None.
def test_db_execution_result_defaults(self):
r = DbExecutionResult(success=True)
assert r.rows_affected == 0
assert r.execution_time_ms == 0.0
assert r.error is None
assert r.db_version is None
# #endregion test_db_execution_result_defaults
# #region test_db_schema_column_defaults [C:2] [TYPE Function]
# @BRIEF DbSchemaColumn defaults: data_type=None.
def test_db_schema_column_defaults(self):
c = DbSchemaColumn(name="id")
assert c.name == "id"
assert c.data_type is None
# #endregion test_db_schema_column_defaults
# #endregion Test.DbExecutor

View File

@@ -0,0 +1,561 @@
# #region Test.Core.FileIO [C:3] [TYPE Module] [SEMANTICS test,fileio,core]
# @BRIEF Coverage tests for fileio.py missed lines: read/write/archive/retention/yaml/async/consolidate.
# @RELATION BINDS_TO -> [FileIOModule]
# @TEST_EDGE: missing_file -> FileNotFoundError / assertion
# @TEST_EDGE: invalid_type -> InvalidZipFormatError on bad ZIP content
# @TEST_EDGE: external_fail -> OSError on cleanup / permission denied
from datetime import date, timedelta
from pathlib import Path
from unittest.mock import MagicMock, patch
import io
import os
import zipfile
import pytest
from src.core.utils.fileio import (
InvalidZipFormatError,
RetentionPolicy,
_update_yaml_file,
apply_retention_policy,
archive_exports,
asave_and_unpack_dashboard,
aread_dashboard_from_disk,
consolidate_archive_folders,
create_dashboard_export,
create_temp_file,
get_filename_from_headers,
read_dashboard_from_disk,
remove_empty_directories,
save_and_unpack_dashboard,
update_yamls,
_unzip_to_directory,
)
def _make_zip_bytes(files=None):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, data in (files or [("f.txt", b"data")]):
zf.writestr(name, data)
return buf.getvalue()
# ── create_temp_file: dir cleanup (58-59) + OSError (63-64) ──────────
# #region test_create_temp_dir_cleanup [C:2] [TYPE Function]
# @BRIEF Verify shutil.rmtree cleanup in finally block (lines 58-59).
def test_create_temp_dir_cleanup():
import tempfile as _tf
real_td = _tf.TemporaryDirectory
class PassthroughDir:
"""Context manager that creates a temp dir but does NOT auto-cleanup."""
def __init__(self, **kw):
self._d = real_td(**kw)
self.name = self._d.name
def __enter__(self):
return self._d.__enter__()
def __exit__(self, *a):
self._d._finalizer.detach() # prevent auto-cleanup
self._d._finalizer = None
return False
saved = None
with patch("src.core.utils.fileio.tempfile.TemporaryDirectory", PassthroughDir):
with create_temp_file(suffix=".dir") as p:
saved = Path(str(p))
assert saved.is_dir()
assert not saved.exists()
# #endregion test_create_temp_dir_cleanup
# #region test_create_temp_cleanup_oserror [C:2] [TYPE Function]
# @BRIEF Verify OSError during cleanup is caught (lines 63-64).
def test_create_temp_cleanup_oserror(monkeypatch):
orig_unlink = Path.unlink
def fail_unlink(self):
raise OSError("permission denied")
monkeypatch.setattr(Path, "unlink", fail_unlink)
with create_temp_file(content=b"x", suffix=".txt") as p:
saved = p
assert saved.exists()
orig_unlink(saved)
# #endregion test_create_temp_cleanup_oserror
# ── remove_empty_directories: not-found (76-77) + OSError (84-85) ────
# #region test_remove_empty_dirs_not_found [C:2] [TYPE Function]
# @BRIEF Non-existent directory returns 0 (lines 76-77).
def test_remove_empty_dirs_not_found():
assert remove_empty_directories("/nonexistent/path/xyz") == 0
# #endregion test_remove_empty_dirs_not_found
# #region test_remove_empty_dirs_oserror [C:2] [TYPE Function]
# @BRIEF OSError during rmdir is caught (lines 84-85).
def test_remove_empty_dirs_oserror(monkeypatch, tmp_path):
d = tmp_path / "empty"
d.mkdir()
monkeypatch.setattr(os, "rmdir", MagicMock(side_effect=OSError("fail")))
assert remove_empty_directories(str(tmp_path)) == 0
# #endregion test_remove_empty_dirs_oserror
# ── read_dashboard_from_disk (95-102) ─────────────────────────────────
# #region test_read_dashboard_from_disk [C:2] [TYPE Function]
# @BRIEF Reads file content and returns (bytes, name) (lines 95-102).
def test_read_dashboard_from_disk(tmp_path):
f = tmp_path / "dash.zip"
f.write_bytes(b"\x00\x01\x02")
content, name = read_dashboard_from_disk(str(f))
assert content == b"\x00\x01\x02"
assert name == "dash.zip"
# #endregion test_read_dashboard_from_disk
# #region test_read_dashboard_empty_file [C:2] [TYPE Function]
# @BRIEF Empty file returns empty bytes (lines 100-101).
def test_read_dashboard_empty_file(tmp_path):
f = tmp_path / "empty.zip"
f.write_bytes(b"")
content, _ = read_dashboard_from_disk(str(f))
assert content == b""
# #endregion test_read_dashboard_empty_file
# #region test_read_dashboard_not_found [C:2] [TYPE Function]
# @BRIEF Missing file raises AssertionError (line 97).
def test_read_dashboard_not_found():
with pytest.raises(AssertionError, match="не найден"):
read_dashboard_from_disk("/no/such/file.zip")
# #endregion test_read_dashboard_not_found
# ── apply_retention_policy (205-229) ──────────────────────────────────
# #region test_apply_retention_policy_buckets [C:2] [TYPE Function]
# @BRIEF Files bucketed into daily/weekly/monthly; too-old excluded (lines 205-229).
def test_apply_retention_policy_buckets(tmp_path, monkeypatch):
today = date(2025, 6, 1)
monkeypatch.setattr("src.core.utils.fileio.date", MagicMock(today=MagicMock(return_value=today)))
files = []
for days_ago in [1, 3, 10, 20, 60, 120, 400]:
p = tmp_path / f"f_{days_ago}.zip"
p.write_bytes(b"x")
files.append((p, today - timedelta(days=days_ago)))
policy = RetentionPolicy(daily=7, weekly=4, monthly=12)
kept = apply_retention_policy(files, policy)
assert (tmp_path / "f_1.zip") in kept
assert (tmp_path / "f_3.zip") in kept
assert (tmp_path / "f_10.zip") in kept
assert (tmp_path / "f_400.zip") not in kept
# #endregion test_apply_retention_policy_buckets
# ── archive_exports (131-197) ─────────────────────────────────────────
# #region test_archive_exports_no_dir [C:2] [TYPE Function]
# @BRIEF Non-existent dir returns early (lines 133-135).
def test_archive_exports_no_dir():
archive_exports("/no/such/dir", RetentionPolicy())
# #endregion test_archive_exports_no_dir
# #region test_archive_exports_no_zips [C:2] [TYPE Function]
# @BRIEF Empty dir returns early (lines 140-142).
def test_archive_exports_no_zips(tmp_path):
archive_exports(str(tmp_path), RetentionPolicy())
# #endregion test_archive_exports_no_zips
# #region test_archive_exports_dedup [C:2] [TYPE Function]
# @BRIEF Deduplication removes older copy; retention cleans the rest (lines 144-197).
def test_archive_exports_dedup(tmp_path):
z = _make_zip_bytes([("a.txt", b"same")])
(tmp_path / "old_20250101_120000.zip").write_bytes(z)
(tmp_path / "new_20250501_120000.zip").write_bytes(z)
archive_exports(str(tmp_path), RetentionPolicy(daily=365), deduplicate=True)
remaining = list(tmp_path.glob("*.zip"))
assert len(remaining) <= 1
# #endregion test_archive_exports_dedup
# ── save_and_unpack_dashboard (237-254) ───────────────────────────────
# #region test_save_and_unpack_basic [C:2] [TYPE Function]
# @BRIEF Save without unpack returns (path, None) (lines 237-251).
def test_save_and_unpack_basic(tmp_path):
z = _make_zip_bytes()
zp, out = save_and_unpack_dashboard(z, str(tmp_path / "out"), original_filename="test.zip")
assert zp.exists() and zp.name == "test.zip"
assert out is None
# #endregion test_save_and_unpack_basic
# #region test_save_and_unpack_with_unpack [C:2] [TYPE Function]
# @BRIEF Save + unpack extracts files (lines 246-250).
def test_save_and_unpack_with_unpack(tmp_path):
z = _make_zip_bytes([("inner.txt", b"hello")])
zp, out = save_and_unpack_dashboard(z, str(tmp_path / "out"), unpack=True)
assert out is not None
assert (out / "inner.txt").read_bytes() == b"hello"
# #endregion test_save_and_unpack_with_unpack
# #region test_save_and_unpack_invalid_zip [C:2] [TYPE Function]
# @BRIEF Bad ZIP raises InvalidZipFormatError (lines 252-254).
def test_save_and_unpack_invalid_zip(tmp_path):
with pytest.raises(InvalidZipFormatError):
save_and_unpack_dashboard(b"not-a-zip", str(tmp_path), unpack=True)
# #endregion test_save_and_unpack_invalid_zip
# ── update_yamls + _update_yaml_file (263-333) ───────────────────────
# #region test_update_yamls_basic [C:2] [TYPE Function]
# @BRIEF update_yamls iterates YAML files and delegates (lines 263-271).
def test_update_yamls_basic(tmp_path):
(tmp_path / "a.yaml").write_text("host: old_server\nport: 3306")
update_yamls(db_configs=[{"old": {"host": "old_server"}, "new": {"host": "new_server"}}], path=str(tmp_path))
assert "new_server" in (tmp_path / "a.yaml").read_text()
# #endregion test_update_yamls_basic
# #region test_update_yaml_regex [C:2] [TYPE Function]
# @BRIEF Regex replacement in YAML file (lines 287-295).
def test_update_yaml_regex(tmp_path):
f = tmp_path / "c.yaml"
f.write_text("value: old_text")
_update_yaml_file(f, [], r"old_text", "new_text")
assert "new_text" in f.read_text()
# #endregion test_update_yaml_regex
# #region test_update_yaml_configs [C:2] [TYPE Function]
# @BRIEF Config-based old→new replacement (lines 297-333).
def test_update_yaml_configs(tmp_path):
f = tmp_path / "d.yaml"
f.write_text('host: "old_host"\nport: "3306"')
_update_yaml_file(f, [{"old": {"host": "old_host"}, "new": {"host": "new_host"}}], None, None)
result = f.read_text()
assert "new_host" in result
assert "old_host" not in result
# #endregion test_update_yaml_configs
# #region test_update_yaml_read_error [C:2] [TYPE Function]
# @BRIEF Read error is caught and logged (lines 283-285).
def test_update_yaml_read_error(tmp_path):
f = tmp_path / "bad.yaml"
f.write_text("x: 1")
with patch("builtins.open", side_effect=IOError("fail")):
_update_yaml_file(f, [], None, None)
# #endregion test_update_yaml_read_error
# #region test_update_yaml_regex_no_change [C:2] [TYPE Function]
# @BRIEF Regex that doesn't match skips rewrite (line 290).
def test_update_yaml_regex_no_change(tmp_path):
f = tmp_path / "e.yaml"
f.write_text("value: unchanged")
_update_yaml_file(f, [], r"NONEXISTENT_PATTERN", "replacement")
assert f.read_text() == "value: unchanged"
# #endregion test_update_yaml_regex_no_change
# ── consolidate_archive_folders (417, 422, 425-426, 431-432) ─────────
# #region test_consolidate_skip_target [C:2] [TYPE Function]
# @BRIEF source_dir == target_dir hits continue (line 417).
def test_consolidate_skip_target(tmp_path):
(tmp_path / "sales").mkdir()
(tmp_path / "sales" / "a.zip").write_bytes(b"z")
(tmp_path / "sales_2024").mkdir()
(tmp_path / "sales_2024" / "b.zip").write_bytes(b"z")
consolidate_archive_folders(tmp_path)
assert (tmp_path / "sales" / "a.zip").exists()
# #endregion test_consolidate_skip_target
# #region test_consolidate_move_dir [C:2] [TYPE Function]
# @BRIEF shutil.move for subdirectory (line 422).
def test_consolidate_move_dir(tmp_path):
(tmp_path / "a_1").mkdir()
(tmp_path / "a_1" / "f.zip").write_bytes(b"z")
(tmp_path / "a_1" / "sub").mkdir()
(tmp_path / "a_2").mkdir()
(tmp_path / "a_2" / "g.zip").write_bytes(b"z")
consolidate_archive_folders(tmp_path)
assert (tmp_path / "a").is_dir()
# #endregion test_consolidate_move_dir
# #region test_consolidate_move_error [C:2] [TYPE Function]
# @BRIEF Exception during shutil.move is caught (lines 425-426).
def test_consolidate_move_error(tmp_path):
(tmp_path / "x_1").mkdir()
(tmp_path / "x_1" / "f.zip").write_bytes(b"z")
(tmp_path / "x_1" / "sub").mkdir()
(tmp_path / "x_2").mkdir()
(tmp_path / "x_2" / "g.zip").write_bytes(b"z")
real_move = __import__("shutil").move
def fail_for_subdir(src, dst):
if "sub" in str(src):
raise OSError("move fail")
return real_move(src, dst)
with patch("src.core.utils.fileio.shutil.move", side_effect=fail_for_subdir):
consolidate_archive_folders(tmp_path)
# #endregion test_consolidate_move_error
# #region test_consolidate_rmdir_error [C:2] [TYPE Function]
# @BRIEF Exception during source rmdir is caught (lines 431-432).
def test_consolidate_rmdir_error(tmp_path):
(tmp_path / "r_1").mkdir()
(tmp_path / "r_1" / "a.zip").write_bytes(b"z")
(tmp_path / "r_2").mkdir()
(tmp_path / "r_2" / "b.zip").write_bytes(b"z")
(tmp_path / "r_2" / "sub").mkdir()
(tmp_path / "r_2" / "sub" / "keep.txt").write_text("x")
real_move = __import__("shutil").move
def fail_sub(src, dst):
if "sub" in str(src):
raise OSError("nope")
return real_move(src, dst)
with patch("src.core.utils.fileio.shutil.move", side_effect=fail_sub):
consolidate_archive_folders(tmp_path)
# #endregion test_consolidate_rmdir_error
# ── aread_dashboard_from_disk (457, 459-460, 464) ────────────────────
# #region test_aread_not_found [C:2] [TYPE Function]
# @BRIEF Missing file raises FileNotFoundError (line 457).
@pytest.mark.asyncio
async def test_aread_not_found():
with pytest.raises(FileNotFoundError):
await aread_dashboard_from_disk("/no/such/file.zip")
# #endregion test_aread_not_found
# #region test_aread_large_file_aiofiles [C:2] [TYPE Function]
# @BRIEF File >10MB uses aiofiles path (lines 459-460).
@pytest.mark.asyncio
async def test_aread_large_file_aiofiles(tmp_path):
from src.core.utils.executors import init_executors, shutdown_executors
init_executors(file_workers=2)
try:
big = tmp_path / "big.zip"
big.write_bytes(b"A" * (11 * 1024 * 1024))
content = await aread_dashboard_from_disk(str(big))
assert len(content) == 11 * 1024 * 1024
finally:
shutdown_executors()
# #endregion test_aread_large_file_aiofiles
# #region test_aread_empty_file [C:2] [TYPE Function]
# @BRIEF Empty file triggers warning (line 464).
@pytest.mark.asyncio
async def test_aread_empty_file(tmp_path):
from src.core.utils.executors import init_executors, shutdown_executors
init_executors(file_workers=2)
try:
f = tmp_path / "empty.zip"
f.write_bytes(b"")
content = await aread_dashboard_from_disk(str(f))
assert content == b""
finally:
shutdown_executors()
# #endregion test_aread_empty_file
# ── asave_and_unpack_dashboard (480-508) ──────────────────────────────
# #region test_asave_basic [C:2] [TYPE Function]
# @BRIEF Async save without unpack (lines 480-508).
@pytest.mark.asyncio
async def test_asave_basic(tmp_path):
from src.core.utils.executors import init_executors, shutdown_executors
init_executors(file_workers=2)
try:
z = _make_zip_bytes()
zp, out = await asave_and_unpack_dashboard(z, str(tmp_path / "out"), original_filename="a.zip")
assert zp.exists() and zp.name == "a.zip"
assert out is None
finally:
shutdown_executors()
# #endregion test_asave_basic
# #region test_asave_with_unpack [C:2] [TYPE Function]
# @BRIEF Async save + unpack extracts files (lines 497-507).
@pytest.mark.asyncio
async def test_asave_with_unpack(tmp_path):
from src.core.utils.executors import init_executors, shutdown_executors
init_executors(file_workers=2)
try:
z = _make_zip_bytes([("inner.txt", b"hello")])
zp, out = await asave_and_unpack_dashboard(z, str(tmp_path / "out"), unpack=True)
assert out is not None
assert (out / "inner.txt").read_bytes() == b"hello"
finally:
shutdown_executors()
# #endregion test_asave_with_unpack
# #region test_asave_invalid_zip [C:2] [TYPE Function]
# @BRIEF Invalid ZIP during unpack propagates error (line 500).
@pytest.mark.asyncio
async def test_asave_invalid_zip(tmp_path):
from src.core.utils.executors import init_executors, shutdown_executors
init_executors(file_workers=2)
try:
with pytest.raises(zipfile.BadZipFile):
await asave_and_unpack_dashboard(b"bad", str(tmp_path / "out"), unpack=True)
finally:
shutdown_executors()
# #endregion test_asave_invalid_zip
# ── _unzip_to_directory (515-516) ─────────────────────────────────────
# #region test_unzip_to_directory [C:2] [TYPE Function]
# @BRIEF Helper extracts ZIP contents (lines 515-516).
def test_unzip_to_directory(tmp_path):
z = _make_zip_bytes([("a.txt", b"ok")])
zp = tmp_path / "t.zip"
zp.write_bytes(z)
out = tmp_path / "out"
out.mkdir()
_unzip_to_directory(str(zp), str(out))
assert (out / "a.txt").read_bytes() == b"ok"
# #endregion test_unzip_to_directory
# ── Additional edge-case coverage ─────────────────────────────────────
# #region test_create_temp_no_content_branch [C:2] [TYPE Function]
# @BRIEF No-content branch: file created empty, cleanup runs (branch 50->52, line 62).
def test_create_temp_no_content_branch():
saved = None
with create_temp_file(suffix=".txt") as p:
saved = p
assert p.stat().st_size == 0
assert not saved.exists()
# #endregion test_create_temp_no_content_branch
# #region test_remove_empty_dirs_success [C:2] [TYPE Function]
# @BRIEF Successful removal increments counter (lines 82-83).
def test_remove_empty_dirs_success(tmp_path):
(tmp_path / "e1").mkdir()
(tmp_path / "e2").mkdir()
(tmp_path / "e2" / "nested").mkdir()
count = remove_empty_directories(str(tmp_path))
assert count >= 2
# #endregion test_remove_empty_dirs_success
# #region test_archive_exports_retention_no_dedup [C:2] [TYPE Function]
# @BRIEF Retention without dedup: mtime fallback + old file removed (lines 144→171, 186, 192-197).
def test_archive_exports_retention_no_dedup(tmp_path):
z = _make_zip_bytes()
old = tmp_path / "nodate_old.zip"
old.write_bytes(z)
old.touch()
archive_exports(str(tmp_path), RetentionPolicy(daily=0, weekly=0, monthly=0))
# #endregion test_archive_exports_retention_no_dedup
# #region test_archive_exports_dedup_crc_error [C:2] [TYPE Function]
# @BRIEF CRC32 calculation error is caught (lines 160-161).
def test_archive_exports_dedup_crc_error(tmp_path):
z = _make_zip_bytes()
(tmp_path / "a_20250501_120000.zip").write_bytes(z)
with patch("src.core.utils.fileio.calculate_crc32", side_effect=Exception("crc fail")):
archive_exports(str(tmp_path), RetentionPolicy(daily=365), deduplicate=True)
# #endregion test_archive_exports_dedup_crc_error
# #region test_archive_exports_dedup_unlink_error [C:2] [TYPE Function]
# @BRIEF Duplicate unlink OSError is caught (lines 168-169).
def test_archive_exports_dedup_unlink_error(tmp_path):
z = _make_zip_bytes()
(tmp_path / "old_20250101_120000.zip").write_bytes(z)
(tmp_path / "new_20250501_120000.zip").write_bytes(z)
real_unlink = Path.unlink
def fail_old(self):
if "old_" in str(self):
raise OSError("unlink fail")
return real_unlink(self)
with patch.object(Path, "unlink", fail_old):
archive_exports(str(tmp_path), RetentionPolicy(daily=365), deduplicate=True)
assert (tmp_path / "old_20250101_120000.zip").exists()
# #endregion test_archive_exports_dedup_unlink_error
# #region test_archive_exports_invalid_date [C:2] [TYPE Function]
# @BRIEF Invalid date in filename falls back to mtime (lines 181-182).
def test_archive_exports_invalid_date(tmp_path):
z = _make_zip_bytes()
(tmp_path / "x_99999999_120000.zip").write_bytes(z)
archive_exports(str(tmp_path), RetentionPolicy(daily=365), deduplicate=True)
# #endregion test_archive_exports_invalid_date
# #region test_update_yaml_regex_error [C:2] [TYPE Function]
# @BRIEF Invalid regex pattern is caught (lines 294-295).
def test_update_yaml_regex_error(tmp_path):
f = tmp_path / "bad_re.yaml"
f.write_text("value: test")
_update_yaml_file(f, [], r"[invalid(", "replacement")
assert f.read_text() == "value: test"
# #endregion test_update_yaml_regex_error
# #region test_update_yaml_config_error [C:2] [TYPE Function]
# @BRIEF Config replacement error is caught (lines 332-333).
def test_update_yaml_config_error(tmp_path):
f = tmp_path / "cfg_err.yaml"
f.write_text("key: val")
with patch("re.sub", side_effect=RuntimeError("sub fail")):
_update_yaml_file(f, [{"old": {"key": "val"}, "new": {"key": "new"}}], None, None)
# #endregion test_update_yaml_config_error
# #region test_consolidate_single_slug [C:2] [TYPE Function]
# @BRIEF Single-dir slug hits continue (line 409).
def test_consolidate_single_slug(tmp_path):
(tmp_path / "lonely_2024").mkdir()
(tmp_path / "lonely_2024" / "a.zip").write_bytes(b"z")
consolidate_archive_folders(tmp_path)
assert (tmp_path / "lonely_2024").exists()
# #endregion test_consolidate_single_slug
# #region test_acleanup_directory [C:2] [TYPE Function]
# @BRIEF Async directory cleanup via run_blocking (lines 527-528).
@pytest.mark.asyncio
async def test_acleanup_directory(tmp_path):
from src.core.utils.fileio import acleanup_directory
from src.core.utils.executors import init_executors, shutdown_executors
init_executors(file_workers=2)
try:
d = tmp_path / "to_clean"
d.mkdir()
(d / "f.txt").write_text("x")
await acleanup_directory(str(d))
assert not d.exists()
finally:
shutdown_executors()
# #endregion test_acleanup_directory
# #region test_aread_bytes_func [C:2] [TYPE Function]
# @BRIEF Async read bytes via run_blocking (lines 538-539).
@pytest.mark.asyncio
async def test_aread_bytes_func(tmp_path):
from src.core.utils.fileio import aread_bytes
from src.core.utils.executors import init_executors, shutdown_executors
init_executors(file_workers=2)
try:
f = tmp_path / "data.bin"
f.write_bytes(b"\xde\xad\xbe\xef")
result = await aread_bytes(f)
assert result == b"\xde\xad\xbe\xef"
finally:
shutdown_executors()
# #endregion test_aread_bytes_func
# ── create_dashboard_export (341-357) + get_filename_from_headers (374-378) ─
# #region test_create_dashboard_export_basic [C:2] [TYPE Function]
# @BRIEF Creates ZIP from source paths (lines 341-357).
def test_create_dashboard_export_basic(tmp_path):
src = tmp_path / "src"
src.mkdir()
(src / "dash.json").write_text("{}")
(src / "meta.yaml").write_text("v: 1")
zp = tmp_path / "export.zip"
assert create_dashboard_export(zp, [str(src)]) is True
assert zp.exists()
with zipfile.ZipFile(zp) as zf:
assert any("dash.json" in n for n in zf.namelist())
# #endregion test_create_dashboard_export_basic
# #region test_create_dashboard_export_fail [C:2] [TYPE Function]
# @BRIEF Non-existent source returns False (lines 355-357).
def test_create_dashboard_export_fail():
assert create_dashboard_export("/tmp/x.zip", ["/nonexistent"]) is False
# #endregion test_create_dashboard_export_fail
# #region test_get_filename_from_headers_cov [C:2] [TYPE Function]
# @BRIEF Extracts filename from Content-Disposition header (lines 374-378).
def test_get_filename_from_headers_cov():
assert get_filename_from_headers({"Content-Disposition": 'attachment; filename="r.pdf"'}) == "r.pdf"
assert get_filename_from_headers({}) is None
# #endregion test_get_filename_from_headers_cov
# #endregion Test.Core.FileIO

View File

@@ -1,112 +1,171 @@
# #region Test.Encryption [C:3] [TYPE Module] [SEMANTICS test,encryption,fernet,key,crypto]
# @BRIEF Tests for core/encryption.py and core/encryption_key.py — Fernet key loading, encrypt/decrypt.
# @BRIEF Verify EncryptionManager encrypt/decrypt and ensure_encryption_key resolution contracts.
# @RELATION BINDS_TO -> [EncryptionCore]
# @RELATION BINDS_TO -> [EncryptionKeyModule]
# @TEST_EDGE: missing_key -> RuntimeError when ENCRYPTION_KEY absent
# @TEST_EDGE: invalid_key -> RuntimeError when ENCRYPTION_KEY is not valid Fernet format
# @TEST_EDGE: external_fail -> decrypt of garbage data raises Exception
# @TEST_INVARIANT: symmetric_encryption -> VERIFIED_BY: encrypt_decrypt_cycle, empty_string_encryption
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import os
import tempfile
from unittest.mock import patch, MagicMock
import pytest
# Hardcoded valid Fernet key — generated once, frozen as fixture.
# 32 url-safe base64 bytes. Never recompute at test time (anti-tautology).
_VALID_FERNET_KEY = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
# ── _require_fernet_key ─────────────────────────────────────────────────────
class TestRequireFernetKey:
"""_require_fernet_key — loads and validates Fernet key from environment."""
def test_valid_key(self):
# #region test_require_key_valid [C:2] [TYPE Function]
# @BRIEF Valid ENCRYPTION_KEY returns key bytes without error.
def test_require_key_valid(self, monkeypatch):
from src.core.encryption import _require_fernet_key
key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True):
result = _require_fernet_key()
assert result == key.encode()
def test_missing_key_raises(self):
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
result = _require_fernet_key()
assert result == _VALID_FERNET_KEY.encode()
# #endregion test_require_key_valid
# #region test_require_key_missing [C:2] [TYPE Function]
# @BRIEF Missing ENCRYPTION_KEY raises RuntimeError with startup guidance.
def test_require_key_missing(self, monkeypatch):
from src.core.encryption import _require_fernet_key
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
_require_fernet_key()
def test_invalid_key_raises(self):
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
_require_fernet_key()
# #endregion test_require_key_missing
# #region test_require_key_invalid [C:2] [TYPE Function]
# @BRIEF Malformed ENCRYPTION_KEY raises RuntimeError about valid Fernet key.
def test_require_key_invalid(self, monkeypatch):
from src.core.encryption import _require_fernet_key
with patch.dict(os.environ, {"ENCRYPTION_KEY": "not-a-valid-fernet-key"}, clear=True):
with pytest.raises(RuntimeError, match="valid Fernet key"):
_require_fernet_key()
def test_empty_key_raises(self):
monkeypatch.setenv("ENCRYPTION_KEY", "not-a-valid-fernet-key")
with pytest.raises(RuntimeError, match="valid Fernet key"):
_require_fernet_key()
# #endregion test_require_key_invalid
# #region test_require_key_whitespace_only [C:2] [TYPE Function]
# @BRIEF Whitespace-only ENCRYPTION_KEY is treated as missing.
def test_require_key_whitespace_only(self, monkeypatch):
from src.core.encryption import _require_fernet_key
with patch.dict(os.environ, {"ENCRYPTION_KEY": " "}, clear=True):
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
_require_fernet_key()
monkeypatch.setenv("ENCRYPTION_KEY", " ")
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
_require_fernet_key()
# #endregion test_require_key_whitespace_only
# ── EncryptionManager ───────────────────────────────────────────────────────
class TestEncryptionManager:
"""EncryptionManager — encrypt and decrypt operations."""
"""EncryptionManager — encrypt/decrypt operations with validated Fernet key."""
def test_encrypt_decrypt_cycle(self):
# #region test_encrypt_decrypt_cycle [C:2] [TYPE Function]
# @BRIEF Encrypt then decrypt returns original plaintext (symmetric invariant).
def test_encrypt_decrypt_cycle(self, monkeypatch):
from src.core.encryption import EncryptionManager
key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True):
mgr = EncryptionManager()
encrypted = mgr.encrypt("my_secret_data")
assert encrypted != "my_secret_data"
decrypted = mgr.decrypt(encrypted)
assert decrypted == "my_secret_data"
def test_empty_string_encryption(self):
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
mgr = EncryptionManager()
# Hardcoded fixture — plaintext declared before assertion, not computed.
plaintext = "my_secret_key"
encrypted = mgr.encrypt(plaintext)
assert encrypted != plaintext
assert mgr.decrypt(encrypted) == plaintext
# #endregion test_encrypt_decrypt_cycle
# #region test_empty_string_encryption [C:2] [TYPE Function]
# @BRIEF Empty string survives encrypt/decrypt cycle.
def test_empty_string_encryption(self, monkeypatch):
from src.core.encryption import EncryptionManager
key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True):
mgr = EncryptionManager()
encrypted = mgr.encrypt("")
decrypted = mgr.decrypt(encrypted)
assert decrypted == ""
def test_decrypt_invalid_data_raises(self):
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
mgr = EncryptionManager()
encrypted = mgr.encrypt("")
assert mgr.decrypt(encrypted) == ""
# #endregion test_empty_string_encryption
# #region test_decrypt_garbage_raises [C:2] [TYPE Function]
# @BRIEF Decrypting non-Fernet data raises an exception (external_fail edge).
def test_decrypt_garbage_raises(self, monkeypatch):
from src.core.encryption import EncryptionManager
key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True):
mgr = EncryptionManager()
with pytest.raises(Exception):
mgr.decrypt("not-encrypted-data")
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
mgr = EncryptionManager()
with pytest.raises(Exception):
mgr.decrypt("not-encrypted-data")
# #endregion test_decrypt_garbage_raises
# #region test_init_missing_key_raises [C:2] [TYPE Function]
# @BRIEF EncryptionManager() without ENCRYPTION_KEY raises RuntimeError at init.
def test_init_missing_key_raises(self, monkeypatch):
from src.core.encryption import EncryptionManager
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
EncryptionManager()
# #endregion test_init_missing_key_raises
# ── ensure_encryption_key ───────────────────────────────────────────────────
class TestEnsureEncryptionKey:
"""ensure_encryption_key — resolve key from env or .env file."""
"""ensure_encryption_key — resolve key from env or .env file, crash-early if absent."""
def test_from_environment(self):
# #region test_ensure_key_from_env [C:2] [TYPE Function]
# @BRIEF Returns key from process environment when ENCRYPTION_KEY is set.
def test_ensure_key_from_env(self, monkeypatch):
from src.core.encryption_key import ensure_encryption_key
key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
with patch.dict(os.environ, {"ENCRYPTION_KEY": key}, clear=True):
result = ensure_encryption_key()
assert result == key
def test_from_env_file(self):
from src.core.encryption_key import ensure_encryption_key
key = "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0="
with patch.dict(os.environ, {}, clear=True):
with tempfile.TemporaryDirectory() as tmpdir:
env_file = Path(tmpdir) / ".env"
env_file.write_text(f"ENCRYPTION_KEY={key}\nOTHER=value\n")
result = ensure_encryption_key(env_file)
assert result == key
# Should also set it in environment
assert os.environ.get("ENCRYPTION_KEY") == key
monkeypatch.setenv("ENCRYPTION_KEY", _VALID_FERNET_KEY)
result = ensure_encryption_key()
assert result == _VALID_FERNET_KEY
# #endregion test_ensure_key_from_env
def test_env_file_not_found_raises(self):
# #region test_ensure_key_from_dotenv_file [C:2] [TYPE Function]
# @BRIEF Loads key from .env file when not in process environment; sets os.environ.
def test_ensure_key_from_dotenv_file(self, monkeypatch, tmp_path):
from src.core.encryption_key import ensure_encryption_key
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"):
ensure_encryption_key(Path("/nonexistent/.env"))
def test_env_file_without_key_raises(self):
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
env_file = tmp_path / ".env"
env_file.write_text(f"ENCRYPTION_KEY={_VALID_FERNET_KEY}\nOTHER=value\n")
result = ensure_encryption_key(env_file)
assert result == _VALID_FERNET_KEY
# Side-effect: key must be propagated to process environment.
import os
assert os.environ.get("ENCRYPTION_KEY") == _VALID_FERNET_KEY
# #endregion test_ensure_key_from_dotenv_file
# #region test_ensure_key_no_file_raises [C:2] [TYPE Function]
# @BRIEF Crash-early when no .env file exists and env var is unset.
def test_ensure_key_no_file_raises(self, monkeypatch):
from src.core.encryption_key import ensure_encryption_key
with patch.dict(os.environ, {}, clear=True):
with tempfile.TemporaryDirectory() as tmpdir:
env_file = Path(tmpdir) / ".env"
env_file.write_text("OTHER=value\n")
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"):
ensure_encryption_key(env_file)
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"):
ensure_encryption_key(Path("/nonexistent/.env"))
# #endregion test_ensure_key_no_file_raises
# #region test_ensure_key_file_without_key_raises [C:2] [TYPE Function]
# @BRIEF Crash-early when .env file exists but contains no ENCRYPTION_KEY line.
def test_ensure_key_file_without_key_raises(self, monkeypatch, tmp_path):
from src.core.encryption_key import ensure_encryption_key
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
env_file = tmp_path / ".env"
env_file.write_text("OTHER=value\nUNRELATED=stuff\n")
with pytest.raises(RuntimeError, match="ENCRYPTION_KEY is not set"):
ensure_encryption_key(env_file)
# #endregion test_ensure_key_file_without_key_raises
# #endregion Test.Encryption

View File

@@ -1,92 +1,227 @@
# #region Test.RateLimiter [C:3] [TYPE Module] [SEMANTICS test,rate_limit,auth,throttle]
# @BRIEF Tests for core/rate_limiter.py — in-memory rate limiter, ban/window logic.
# #region Test.RateLimiter [C:3] [TYPE Module] [SEMANTICS test,rate_limiter,security,throttle]
# @BRIEF Verify RateLimiterModule contracts — ban/window logic, pruning, thread safety.
# @RELATION BINDS_TO -> [RateLimiterModule]
# @TEST_EDGE: unknown_ip_not_banned -> is_banned returns False for unseen IP
# @TEST_EDGE: ban_triggers_at_limit -> MAX_ATTEMPTS+1 attempts triggers ban
# @TEST_EDGE: exactly_at_limit_no_ban -> exactly MAX_ATTEMPTS does NOT ban (code uses >)
# @TEST_EDGE: ban_expires -> ban auto-clears after BAN_DURATION seconds
# @TEST_EDGE: old_attempts_pruned -> attempts outside window are discarded
# @TEST_EDGE: success_clears_attempts -> record_success resets attempt counter
# @TEST_EDGE: success_does_not_clear_ban -> record_success does NOT lift active ban
# @TEST_EDGE: ban_clears_attempts -> after ban, attempt history is deleted
from pathlib import Path
import sys
import threading
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from unittest.mock import patch, MagicMock
from unittest.mock import patch
import pytest
from src.core.rate_limiter import RateLimiter
# ── Constant overrides for fast, deterministic tests ──
# MAX_ATTEMPTS=2 → ban triggers on 3rd attempt (production code uses `>` not `>=`)
# ATTEMPT_WINDOW=3600 → 1-hour window (attempts stay valid within a single test)
# BAN_DURATION=3600 → 1-hour ban (testable via mocked monotonic clock)
@pytest.fixture(autouse=True)
def _fast_constants(monkeypatch):
"""Monkeypatch module constants to small values for fast testing."""
monkeypatch.setattr("src.core.rate_limiter.MAX_ATTEMPTS", 2)
monkeypatch.setattr("src.core.rate_limiter.ATTEMPT_WINDOW", 3600)
monkeypatch.setattr("src.core.rate_limiter.BAN_DURATION", 3600)
@pytest.fixture
def limiter():
"""Fresh RateLimiter instance per test — no shared state."""
return RateLimiter()
class TestRateLimiter:
"""RateLimiter — in-memory sliding-window rate limiter."""
"""RateLimiter — in-memory sliding-window rate limiter keyed by IP."""
@pytest.fixture
def limiter(self):
from src.core.rate_limiter import RateLimiter
return RateLimiter()
def test_is_banned_initial_false(self, limiter):
# #region test_is_banned_unknown_ip [C:2] [TYPE Function]
# @BRIEF Unknown IP has no ban entry — is_banned returns False.
def test_is_banned_unknown_ip(self, limiter):
assert limiter.is_banned("192.168.1.1") is False
# #endregion test_is_banned_unknown_ip
# #region test_is_banned_banned_ip [C:2] [TYPE Function]
# @BRIEF IP with 3 attempts (MAX_ATTEMPTS+1) is banned — is_banned returns True.
def test_is_banned_banned_ip(self, limiter):
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
# Must check inside patch — real monotonic would exceed ban expiry
assert limiter.is_banned("10.0.0.1") is True
# #endregion test_is_banned_banned_ip
# #region test_ban_expires_after_duration [C:2] [TYPE Function]
# @BRIEF Ban auto-clears once monotonic clock exceeds ban expiry.
def test_ban_expires_after_duration(self, limiter):
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is True
# Advance clock past BAN_DURATION (3600s) + 1s margin
mock_time.monotonic.return_value = 4601.0
assert limiter.is_banned("10.0.0.1") is False
# #endregion test_ban_expires_after_duration
# #region test_record_attempt_under_limit [C:2] [TYPE Function]
# @BRIEF 2 attempts (exactly MAX_ATTEMPTS) does NOT trigger ban.
def test_record_attempt_under_limit(self, limiter):
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
for i in range(5): # 5 attempts, well under MAX_ATTEMPTS=10
limiter.record_attempt("192.168.1.1")
assert limiter.is_banned("192.168.1.1") is False
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
def test_record_attempt_exceeds_limit(self, limiter):
from src.core.rate_limiter import MAX_ATTEMPTS
assert limiter.is_banned("10.0.0.1") is False
# #endregion test_record_attempt_under_limit
# #region test_record_attempt_triggers_ban [C:2] [TYPE Function]
# @BRIEF 3rd attempt (MAX_ATTEMPTS+1) triggers ban — code uses `>` not `>=`.
def test_record_attempt_triggers_ban(self, limiter):
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
for i in range(MAX_ATTEMPTS + 1):
limiter.record_attempt("192.168.1.1")
assert limiter.is_banned("192.168.1.1") is True
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is False
def test_ban_expires(self, limiter):
from src.core.rate_limiter import MAX_ATTEMPTS, BAN_DURATION
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
for i in range(MAX_ATTEMPTS + 1):
limiter.record_attempt("192.168.1.1")
assert limiter.is_banned("192.168.1.1") is True
# Advance time past ban duration
mock_time.monotonic.return_value = 1000.0 + BAN_DURATION + 1
assert limiter.is_banned("192.168.1.1") is False
limiter.record_attempt("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is True
# #endregion test_record_attempt_triggers_ban
# #region test_old_attempts_pruned [C:2] [TYPE Function]
# @BRIEF Attempts older than ATTEMPT_WINDOW are discarded; fresh start after gap.
def test_old_attempts_pruned(self, limiter):
from src.core.rate_limiter import MAX_ATTEMPTS
with patch("src.core.rate_limiter.time") as mock_time:
# 2 attempts at t=1000
mock_time.monotonic.return_value = 1000.0
for i in range(MAX_ATTEMPTS):
limiter.record_attempt("192.168.1.1")
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
# Advance time past window but not to ban expiry
mock_time.monotonic.return_value = 1500.0 # 500s later, past 300s window
# Old attempts should be pruned. One new attempt should not trigger ban.
limiter.record_attempt("192.168.1.1")
assert limiter.is_banned("192.168.1.1") is False
# Advance past ATTEMPT_WINDOW (3600s) — old attempts expire
mock_time.monotonic.return_value = 5000.0
limiter.record_attempt("10.0.0.1")
# Only 1 attempt in window — not banned
assert limiter.is_banned("10.0.0.1") is False
# #endregion test_old_attempts_pruned
# #region test_record_success_clears_attempts [C:2] [TYPE Function]
# @BRIEF record_success resets attempt counter; subsequent attempts start fresh.
def test_record_success_clears_attempts(self, limiter):
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
for i in range(5):
limiter.record_attempt("192.168.1.1")
limiter.record_success("192.168.1.1")
assert limiter.is_banned("192.168.1.1") is False
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
limiter.record_success("10.0.0.1")
# 2 more attempts after success — counter was reset, still under limit
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is False
# #endregion test_record_success_clears_attempts
# #region test_record_success_does_not_clear_ban [C:2] [TYPE Function]
# @BRIEF record_success clears attempts but does NOT lift an active ban.
def test_record_success_does_not_clear_ban(self, limiter):
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is True
# Success clears attempts but ban remains
limiter.record_success("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is True
# #endregion test_record_success_does_not_clear_ban
# #region test_different_ips_independent [C:2] [TYPE Function]
# @BRIEF Ban on one IP does not affect other IPs.
def test_different_ips_independent(self, limiter):
with patch("src.core.rate_limiter.time") as mock_time:
mock_time.monotonic.return_value = 1000.0
for i in range(15): # exceeds limit for ip1
limiter.record_attempt("192.168.1.1")
assert limiter.is_banned("192.168.1.1") is True
# Different IP should not be affected
for _ in range(3):
limiter.record_attempt("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is True
assert limiter.is_banned("192.168.1.1") is False
# #endregion test_different_ips_independent
# #region test_ban_clears_attempts [C:2] [TYPE Function]
# @BRIEF After ban, attempt history is deleted; post-expiry attempts start fresh.
def test_ban_clears_attempts(self, limiter):
with patch("src.core.rate_limiter.time") as mock_time:
# Trigger ban at t=1000
mock_time.monotonic.return_value = 1000.0
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is True
# Advance past ban expiry
mock_time.monotonic.return_value = 4601.0
assert limiter.is_banned("10.0.0.1") is False
# 2 fresh attempts — if old attempts weren't cleared, these would
# stack on top of 3 old ones (5 > 2) and re-trigger ban immediately.
limiter.record_attempt("10.0.0.1")
limiter.record_attempt("10.0.0.1")
assert limiter.is_banned("10.0.0.1") is False
# #endregion test_ban_clears_attempts
# #region test_thread_safety_basic [C:2] [TYPE Function]
# @BRIEF Concurrent record_attempt calls from multiple threads do not raise.
def test_thread_safety_basic(self, limiter):
errors = []
def worker(ip, count):
try:
for _ in range(count):
limiter.record_attempt(ip)
limiter.is_banned(ip)
except Exception as exc:
errors.append(exc)
threads = [
threading.Thread(target=worker, args=("10.0.0.1", 50)),
threading.Thread(target=worker, args=("10.0.0.2", 50)),
threading.Thread(target=worker, args=("10.0.0.3", 50)),
]
for t in threads:
t.start()
for t in threads:
t.join(timeout=5)
assert errors == []
# #endregion test_thread_safety_basic
class TestRateLimiterSingleton:
"""rate_limiter singleton is pre-created."""
"""rate_limiter singleton is pre-created at module level."""
# #region test_singleton_exists [C:2] [TYPE Function]
# @BRIEF Module-level singleton exists and exposes the expected interface.
def test_singleton_exists(self):
from src.core.rate_limiter import rate_limiter
assert rate_limiter is not None
assert hasattr(rate_limiter, "is_banned")
assert hasattr(rate_limiter, "record_attempt")
assert hasattr(rate_limiter, "record_success")
# #endregion test_singleton_exists
# #endregion Test.RateLimiter

View File

@@ -0,0 +1,910 @@
# #region Test.Scheduler [C:3] [TYPE Module] [SEMANTICS test,scheduler,core]
# @BRIEF Unit tests for SchedulerModule — SchedulerService lifecycle, job management, and ThrottledSchedulerConfigurator.
# @RELATION BINDS_TO -> [SchedulerModule]
# @TEST_EDGE: empty_queue -> _trigger_backup with no active tasks creates a new task
# @TEST_EDGE: duplicate_backup -> _trigger_backup skips when backup already running
# @TEST_EDGE: invalid_cron -> add_backup_job handles invalid cron expression gracefully
# @TEST_EDGE: translation_load_failure -> load_schedules continues when translation DB query fails
# @TEST_EDGE: validation_load_failure -> load_schedules continues when validation DB query fails
# @TEST_EDGE: policy_not_found -> reload_validation_policy removes job when policy missing
# @TEST_EDGE: policy_inactive -> reload_validation_policy removes job when policy inactive
# @TEST_EDGE: policy_no_schedule -> reload_validation_policy skips when no schedule_days/window
# @TEST_EDGE: zero_window -> calculate_schedule returns start_dt repeated for zero-length window
# @TEST_EDGE: midnight_crossing -> calculate_schedule handles window crossing midnight
# @TEST_EDGE: single_dashboard -> calculate_schedule returns single start_dt for one dashboard
import asyncio
from datetime import date, datetime, time, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
import pytest
# ── Helpers (C1 — anchor pair only) ──
# #region _build_config_manager [C:1] [TYPE Function]
def _build_config_manager(environments=None, app_timezone="Europe/Moscow"):
"""Build a mock ConfigManager with configurable environments."""
cm = MagicMock()
config = MagicMock()
settings = MagicMock()
settings.app_timezone = app_timezone
config.settings = settings
config.environments = environments or []
cm.get_config.return_value = config
return cm
# #endregion _build_config_manager
# #region _build_env [C:1] [TYPE Function]
def _build_env(env_id="env-1", cron="0 2 * * *", enabled=True):
"""Build a mock Environment with backup_schedule."""
env = MagicMock()
env.id = env_id
sched = MagicMock()
sched.enabled = enabled
sched.cron_expression = cron
env.backup_schedule = sched
return env
# #endregion _build_env
# #region _build_task [C:1] [TYPE Function]
def _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-1"):
"""Build a mock TaskRecord."""
task = MagicMock()
task.plugin_id = plugin_id
task.status = status
task.params = {"environment_id": environment_id}
return task
# #endregion _build_task
# #region _build_policy [C:1] [TYPE Function]
def _build_policy(policy_id="pol-1", is_active=True, schedule_days=None, window_start=None, window_end=None, dashboard_ids=None, environment_id="env-1", provider_id="prov-1"):
"""Build a mock ValidationPolicy."""
policy = MagicMock()
policy.id = policy_id
policy.is_active = is_active
policy.schedule_days = schedule_days
policy.window_start = window_start
policy.window_end = window_end
policy.dashboard_ids = dashboard_ids
policy.environment_id = environment_id
policy.provider_id = provider_id
return policy
# #endregion _build_policy
# ── Fixtures ──
@pytest.fixture
def mock_task_manager():
tm = MagicMock()
tm.get_tasks.return_value = []
tm.create_task = AsyncMock(return_value=MagicMock())
return tm
@pytest.fixture
def mock_config_manager():
return _build_config_manager()
@pytest.fixture
def mock_scheduler():
"""Patch BackgroundScheduler so no real threads are started."""
with patch("src.core.scheduler.BackgroundScheduler") as cls:
sched_instance = MagicMock()
sched_instance.running = False
cls.return_value = sched_instance
yield sched_instance
@pytest.fixture
def service(mock_task_manager, mock_config_manager, mock_scheduler):
"""Construct a SchedulerService with all external deps mocked."""
# Create a fresh event loop to avoid "no current event loop" errors
# when the scheduler test runs after async tests in the same session.
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.get_event_loop", return_value=loop):
from src.core.scheduler import SchedulerService
svc = SchedulerService(mock_task_manager, mock_config_manager)
yield svc
loop.close()
# ══════════════════════════════════════════════════════════════
# SchedulerService.__init__ (lines 28-32)
# ══════════════════════════════════════════════════════════════
# #region test_init_stores_deps [C:2] [TYPE Function]
# @BRIEF __init__ stores task_manager, config_manager, creates scheduler and loop.
def test_init_stores_deps(service, mock_task_manager, mock_config_manager, mock_scheduler):
assert service.task_manager is mock_task_manager
assert service.config_manager is mock_config_manager
assert service.scheduler is mock_scheduler
assert service.loop is not None
# #endregion test_init_stores_deps
# ══════════════════════════════════════════════════════════════
# SchedulerService.start (lines 40-44)
# ══════════════════════════════════════════════════════════════
# #region test_start_when_not_running [C:2] [TYPE Function]
# @BRIEF start() starts scheduler and loads schedules when not already running.
def test_start_when_not_running(service, mock_scheduler):
mock_scheduler.running = False
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(service, "load_schedules") as mock_load:
service.start()
mock_scheduler.start.assert_called_once()
mock_load.assert_called_once()
# #endregion test_start_when_not_running
# #region test_start_when_already_running [C:2] [TYPE Function]
# @BRIEF start() is a no-op when scheduler is already running.
def test_start_when_already_running(service, mock_scheduler):
mock_scheduler.running = True
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch.object(service, "load_schedules") as mock_load:
service.start()
mock_scheduler.start.assert_not_called()
mock_load.assert_not_called()
# #endregion test_start_when_already_running
# ══════════════════════════════════════════════════════════════
# SchedulerService.stop (lines 52-55)
# ══════════════════════════════════════════════════════════════
# #region test_stop_when_running [C:2] [TYPE Function]
# @BRIEF stop() shuts down scheduler when it is running.
def test_stop_when_running(service, mock_scheduler):
mock_scheduler.running = True
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
service.stop()
mock_scheduler.shutdown.assert_called_once()
# #endregion test_stop_when_running
# #region test_stop_when_not_running [C:2] [TYPE Function]
# @BRIEF stop() is a no-op when scheduler is not running.
def test_stop_when_not_running(service, mock_scheduler):
mock_scheduler.running = False
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
service.stop()
mock_scheduler.shutdown.assert_not_called()
# #endregion test_stop_when_not_running
# ══════════════════════════════════════════════════════════════
# SchedulerService.load_schedules (lines 63-130)
# ══════════════════════════════════════════════════════════════
# #region test_load_schedules_backup_jobs [C:2] [TYPE Function]
# @BRIEF load_schedules registers backup jobs for enabled environments.
def test_load_schedules_backup_jobs(service, mock_scheduler, mock_config_manager):
env1 = _build_env("env-1", "0 2 * * *", enabled=True)
env2 = _build_env("env-2", "0 3 * * *", enabled=False)
cm = _build_config_manager(environments=[env1, env2])
service.config_manager = cm
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
# No translation schedules, no validation policies
db_mock.query.return_value.filter.return_value.all.return_value = []
with patch.object(service, "add_backup_job") as mock_add:
service.load_schedules()
# Only env-1 has enabled schedule
mock_add.assert_called_once_with("env-1", "0 2 * * *")
# #endregion test_load_schedules_backup_jobs
# #region test_load_schedules_translation_exception [C:2] [TYPE Function]
# @BRIEF load_schedules catches exception when loading translation schedules (lines 93-94).
def test_load_schedules_translation_exception(service, mock_config_manager):
cm = _build_config_manager(environments=[])
service.config_manager = cm
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=RuntimeError("DB connection lost")):
# Should not raise — exception is caught at line 93
service.load_schedules()
# #endregion test_load_schedules_translation_exception
# #region test_load_schedules_validation_exception [C:2] [TYPE Function]
# @BRIEF load_schedules catches exception when loading validation schedules (lines 128-129).
def test_load_schedules_validation_exception(service, mock_config_manager):
cm = _build_config_manager(environments=[])
service.config_manager = cm
call_count = 0
original_session_local = None
def session_side_effect():
nonlocal call_count
call_count += 1
if call_count == 1:
# First call (translation) — return empty mock
db = MagicMock()
db.query.return_value.filter.return_value.all.return_value = []
return db
else:
# Second call (validation) — raise
raise RuntimeError("Validation DB error")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_side_effect):
# Should not raise — exception caught at line 128
service.load_schedules()
# #endregion test_load_schedules_validation_exception
# #region test_load_schedules_translation_schedules_success [C:2] [TYPE Function]
# @BRIEF load_schedules registers active translation schedules from DB (lines 82, 90).
def test_load_schedules_translation_schedules_success(service, mock_config_manager):
cm = _build_config_manager(environments=[], app_timezone="UTC")
service.config_manager = cm
# Build a mock TranslationSchedule row
ts = MagicMock()
ts.id = "ts-1"
ts.job_id = "job-ts-1"
ts.cron_expression = "*/10 * * * *"
ts.timezone = "US/Pacific"
ts.execution_mode = "incremental"
call_count = 0
def session_factory():
nonlocal call_count
call_count += 1
db = MagicMock()
if call_count == 1:
# Translation query — return one active schedule
db.query.return_value.filter.return_value.all.return_value = [ts]
else:
# Validation query — empty
db.query.return_value.filter.return_value.all.return_value = []
return db
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_factory):
with patch.object(service, "add_translation_job") as mock_add_t:
with patch.object(service, "add_validation_job") as mock_add_v:
service.load_schedules()
mock_add_t.assert_called_once_with(
schedule_id="ts-1",
job_id="job-ts-1",
cron_expression="*/10 * * * *",
timezone="US/Pacific",
execution_mode="incremental",
)
mock_add_v.assert_not_called()
# #endregion test_load_schedules_translation_schedules_success
# #region test_load_schedules_validation_policy_skip_empty_days [C:2] [TYPE Function]
# @BRIEF load_schedules skips validation policies with empty schedule_days (line 112).
def test_load_schedules_validation_policy_skip_empty_days(service, mock_config_manager):
cm = _build_config_manager(environments=[], app_timezone="UTC")
service.config_manager = cm
policy_no_days = _build_policy("pol-1", is_active=True, schedule_days=[], window_start=time(10, 0))
policy_none_days = _build_policy("pol-2", is_active=True, schedule_days=None, window_start=time(10, 0))
call_count = 0
def session_factory():
nonlocal call_count
call_count += 1
db = MagicMock()
if call_count == 1:
# Translation query — empty
db.query.return_value.filter.return_value.all.return_value = []
else:
# Validation query — policies with empty/None schedule_days
db.query.return_value.filter.return_value.all.return_value = [policy_no_days, policy_none_days]
return db
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_factory):
with patch.object(service, "add_validation_job") as mock_add:
service.load_schedules()
# Neither policy should be registered (both have empty/None schedule_days)
mock_add.assert_not_called()
# #endregion test_load_schedules_validation_policy_skip_empty_days
# #region test_load_schedules_validation_policy_skip_no_window [C:2] [TYPE Function]
# @BRIEF load_schedules skips validation policies with None window_start (line 115).
def test_load_schedules_validation_policy_skip_no_window(service, mock_config_manager):
cm = _build_config_manager(environments=[], app_timezone="UTC")
service.config_manager = cm
policy_no_ws = _build_policy("pol-3", is_active=True, schedule_days=[1, 3], window_start=None)
call_count = 0
def session_factory():
nonlocal call_count
call_count += 1
db = MagicMock()
if call_count == 1:
db.query.return_value.filter.return_value.all.return_value = []
else:
db.query.return_value.filter.return_value.all.return_value = [policy_no_ws]
return db
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_factory):
with patch.object(service, "add_validation_job") as mock_add:
service.load_schedules()
mock_add.assert_not_called()
# #endregion test_load_schedules_validation_policy_skip_no_window
# #region test_load_schedules_validation_policy_success [C:2] [TYPE Function]
# @BRIEF load_schedules registers valid validation policies with cron expression.
def test_load_schedules_validation_policy_success(service, mock_config_manager):
cm = _build_config_manager(environments=[], app_timezone="UTC")
service.config_manager = cm
ws = time(10, 30)
policy = _build_policy("pol-ok", is_active=True, schedule_days=[1, 3, 5], window_start=ws)
call_count = 0
def session_factory():
nonlocal call_count
call_count += 1
db = MagicMock()
if call_count == 1:
db.query.return_value.filter.return_value.all.return_value = []
else:
db.query.return_value.filter.return_value.all.return_value = [policy]
return db
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=session_factory):
with patch.object(service, "add_validation_job") as mock_add:
service.load_schedules()
# cron: minute=30 hour=10 * * 1,3,5
mock_add.assert_called_once_with(policy_id="pol-ok", cron_expression="30 10 * * 1,3,5", app_timezone="UTC")
# #endregion test_load_schedules_validation_policy_success
# ══════════════════════════════════════════════════════════════
# SchedulerService.add_backup_job (lines 139-157)
# ══════════════════════════════════════════════════════════════
# #region test_add_backup_job_success [C:2] [TYPE Function]
# @BRIEF add_backup_job registers a job with APScheduler.
def test_add_backup_job_success(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
service.add_backup_job("env-1", "0 2 * * *")
mock_scheduler.add_job.assert_called_once()
call_kwargs = mock_scheduler.add_job.call_args
assert call_kwargs.kwargs["id"] == "backup_env-1"
assert call_kwargs.kwargs["replace_existing"] is True
# #endregion test_add_backup_job_success
# #region test_add_backup_job_invalid_cron [C:2] [TYPE Function]
# @BRIEF add_backup_job handles invalid cron expression without raising (lines 156-157).
def test_add_backup_job_invalid_cron(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.side_effect = ValueError("Invalid cron")
# Should not raise
service.add_backup_job("env-bad", "invalid_cron")
mock_scheduler.add_job.assert_not_called()
# #endregion test_add_backup_job_invalid_cron
# ══════════════════════════════════════════════════════════════
# SchedulerService.add_translation_job (lines 167-193)
# ══════════════════════════════════════════════════════════════
# #region test_add_translation_job_success [C:2] [TYPE Function]
# @BRIEF add_translation_job registers a translation job with APScheduler.
def test_add_translation_job_success(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
service.add_translation_job("sched-1", "job-1", "*/5 * * * *", "US/Eastern", "incremental")
mock_scheduler.add_job.assert_called_once()
call_kwargs = mock_scheduler.add_job.call_args
assert call_kwargs.kwargs["id"] == "translate_sched-1"
# #endregion test_add_translation_job_success
# #region test_add_translation_job_exception [C:2] [TYPE Function]
# @BRIEF add_translation_job handles registration failure gracefully (lines 189-190).
def test_add_translation_job_exception(service, mock_scheduler):
mock_scheduler.add_job.side_effect = RuntimeError("APScheduler failure")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
# Should not raise
service.add_translation_job("sched-2", "job-2", "0 0 * * *")
# #endregion test_add_translation_job_exception
# ══════════════════════════════════════════════════════════════
# SchedulerService.remove_translation_job
# ══════════════════════════════════════════════════════════════
# #region test_remove_translation_job_exists [C:2] [TYPE Function]
# @BRIEF remove_translation_job removes an existing job.
def test_remove_translation_job_exists(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
service.remove_translation_job("sched-1")
mock_scheduler.remove_job.assert_called_once_with("translate_sched-1")
# #endregion test_remove_translation_job_exists
# #region test_remove_translation_job_not_found [C:2] [TYPE Function]
# @BRIEF remove_translation_job silently handles missing job.
def test_remove_translation_job_not_found(service, mock_scheduler):
mock_scheduler.remove_job.side_effect = Exception("Job not found")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
# Should not raise
service.remove_translation_job("sched-missing")
# #endregion test_remove_translation_job_not_found
# ══════════════════════════════════════════════════════════════
# SchedulerService._trigger_backup (lines 218-241)
# ══════════════════════════════════════════════════════════════
# #region test_trigger_backup_no_active_tasks [C:2] [TYPE Function]
# @BRIEF _trigger_backup creates a task when no backup is running (lines 219-236).
def test_trigger_backup_no_active_tasks(service, mock_task_manager):
mock_task_manager.get_tasks.return_value = []
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
# create_task is called and its coroutine passed to run_coroutine_threadsafe
mock_run.assert_called_once()
mock_task_manager.create_task.assert_called_once_with(
"superset-backup", {"environment_id": "env-1"}
)
# #endregion test_trigger_backup_no_active_tasks
# #region test_trigger_backup_already_running [C:2] [TYPE Function]
# @BRIEF _trigger_backup skips when a backup is already running for the environment.
def test_trigger_backup_already_running(service, mock_task_manager):
running_task = _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-1")
mock_task_manager.get_tasks.return_value = [running_task]
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
mock_run.assert_not_called()
# #endregion test_trigger_backup_already_running
# #region test_trigger_backup_pending_status [C:2] [TYPE Function]
# @BRIEF _trigger_backup skips when a backup is in PENDING status for the environment.
def test_trigger_backup_pending_status(service, mock_task_manager):
pending_task = _build_task(plugin_id="superset-backup", status="PENDING", environment_id="env-1")
mock_task_manager.get_tasks.return_value = [pending_task]
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
mock_run.assert_not_called()
# #endregion test_trigger_backup_pending_status
# #region test_trigger_backup_different_env [C:2] [TYPE Function]
# @BRIEF _trigger_backup proceeds when running backup is for a different environment.
def test_trigger_backup_different_env(service, mock_task_manager):
other_task = _build_task(plugin_id="superset-backup", status="RUNNING", environment_id="env-2")
mock_task_manager.get_tasks.return_value = [other_task]
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
mock_run.assert_called_once()
# #endregion test_trigger_backup_different_env
# #region test_trigger_backup_other_plugin [C:2] [TYPE Function]
# @BRIEF _trigger_backup proceeds when running task is a different plugin.
def test_trigger_backup_other_plugin(service, mock_task_manager):
other_plugin_task = _build_task(plugin_id="other-plugin", status="RUNNING", environment_id="env-1")
mock_task_manager.get_tasks.return_value = [other_plugin_task]
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_backup("env-1")
mock_run.assert_called_once()
# #endregion test_trigger_backup_other_plugin
# ══════════════════════════════════════════════════════════════
# SchedulerService.add_validation_job
# ══════════════════════════════════════════════════════════════
# #region test_add_validation_job_success [C:2] [TYPE Function]
# @BRIEF add_validation_job registers a validation job with APScheduler.
def test_add_validation_job_success(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
service.add_validation_job("pol-1", "30 10 * * 1,3", app_timezone="UTC")
mock_scheduler.add_job.assert_called_once()
call_kwargs = mock_scheduler.add_job.call_args
assert call_kwargs.kwargs["id"] == "validation_pol-1"
# #endregion test_add_validation_job_success
# #region test_add_validation_job_exception [C:2] [TYPE Function]
# @BRIEF add_validation_job handles registration failure gracefully.
def test_add_validation_job_exception(service, mock_scheduler):
mock_scheduler.add_job.side_effect = RuntimeError("Scheduler error")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.CronTrigger") as mock_trigger:
mock_trigger.from_crontab.return_value = "trigger_obj"
# Should not raise
service.add_validation_job("pol-bad", "30 10 * * 1")
# #endregion test_add_validation_job_exception
# ══════════════════════════════════════════════════════════════
# SchedulerService.remove_validation_job
# ══════════════════════════════════════════════════════════════
# #region test_remove_validation_job_exists [C:2] [TYPE Function]
# @BRIEF remove_validation_job removes an existing job.
def test_remove_validation_job_exists(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
service.remove_validation_job("pol-1")
mock_scheduler.remove_job.assert_called_once_with("validation_pol-1")
# #endregion test_remove_validation_job_exists
# #region test_remove_validation_job_not_found [C:2] [TYPE Function]
# @BRIEF remove_validation_job silently handles missing job.
def test_remove_validation_job_not_found(service, mock_scheduler):
mock_scheduler.remove_job.side_effect = Exception("Not found")
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
# Should not raise
service.remove_validation_job("pol-missing")
# #endregion test_remove_validation_job_not_found
# ══════════════════════════════════════════════════════════════
# SchedulerService.reload_validation_policy (lines 300-331)
# ══════════════════════════════════════════════════════════════
# #region test_reload_validation_policy_not_found [C:2] [TYPE Function]
# @BRIEF reload_validation_policy removes job when policy not found in DB (lines 315-316).
def test_reload_validation_policy_not_found(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = None
with patch.object(service, "remove_validation_job") as mock_remove:
with patch.object(service, "add_validation_job") as mock_add:
service.reload_validation_policy("pol-missing")
mock_remove.assert_called_once_with("pol-missing")
mock_add.assert_not_called()
# #endregion test_reload_validation_policy_not_found
# #region test_reload_validation_policy_inactive [C:2] [TYPE Function]
# @BRIEF reload_validation_policy removes job when policy is inactive (lines 319-320).
def test_reload_validation_policy_inactive(service, mock_scheduler):
policy = _build_policy("pol-inactive", is_active=False)
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
with patch.object(service, "remove_validation_job") as mock_remove:
with patch.object(service, "add_validation_job") as mock_add:
service.reload_validation_policy("pol-inactive")
mock_remove.assert_called_once_with("pol-inactive")
mock_add.assert_not_called()
# #endregion test_reload_validation_policy_inactive
# #region test_reload_validation_policy_no_schedule [C:2] [TYPE Function]
# @BRIEF reload_validation_policy skips registration when no schedule_days or window_start (lines 327-328 area).
def test_reload_validation_policy_no_schedule(service, mock_scheduler):
policy = _build_policy("pol-nosched", is_active=True, schedule_days=[], window_start=None)
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
with patch.object(service, "remove_validation_job") as mock_remove:
with patch.object(service, "add_validation_job") as mock_add:
service.reload_validation_policy("pol-nosched")
mock_remove.assert_called_once_with("pol-nosched")
mock_add.assert_not_called()
# #endregion test_reload_validation_policy_no_schedule
# #region test_reload_validation_policy_success [C:2] [TYPE Function]
# @BRIEF reload_validation_policy removes old and registers new job for active policy with schedule.
def test_reload_validation_policy_success(service, mock_scheduler):
ws = time(14, 0)
policy = _build_policy("pol-ok", is_active=True, schedule_days=[0, 2, 4], window_start=ws)
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
with patch.object(service, "remove_validation_job") as mock_remove:
with patch.object(service, "add_validation_job") as mock_add:
service.reload_validation_policy("pol-ok", app_timezone="UTC")
mock_remove.assert_called_once_with("pol-ok")
# cron: minute=0 hour=14 * * 0,2,4
mock_add.assert_called_once_with("pol-ok", "0 14 * * 0,2,4", app_timezone="UTC")
# #endregion test_reload_validation_policy_success
# #region test_reload_validation_policy_exception [C:2] [TYPE Function]
# @BRIEF reload_validation_policy handles DB exception gracefully (lines 327-328).
def test_reload_validation_policy_exception(service, mock_scheduler):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal", side_effect=RuntimeError("DB down")):
with patch.object(service, "remove_validation_job"):
# Should not raise
service.reload_validation_policy("pol-err")
# #endregion test_reload_validation_policy_exception
# ══════════════════════════════════════════════════════════════
# SchedulerService._trigger_validation (lines 338-391)
# ══════════════════════════════════════════════════════════════
# #region test_trigger_validation_policy_not_found [C:2] [TYPE Function]
# @BRIEF _trigger_validation returns early when policy not found in DB.
def test_trigger_validation_policy_not_found(service, mock_task_manager):
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = None
# Should not raise
service._trigger_validation("pol-missing")
db_mock.close.assert_called_once()
# #endregion test_trigger_validation_policy_not_found
# #region test_trigger_validation_policy_inactive [C:2] [TYPE Function]
# @BRIEF _trigger_validation returns early when policy is inactive.
def test_trigger_validation_policy_inactive(service, mock_task_manager):
policy = _build_policy("pol-inactive", is_active=False)
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
service._trigger_validation("pol-inactive")
mock_task_manager.create_task.assert_not_called()
# #endregion test_trigger_validation_policy_inactive
# #region test_trigger_validation_no_dashboards [C:2] [TYPE Function]
# @BRIEF _trigger_validation returns early when policy has no dashboard_ids.
def test_trigger_validation_no_dashboards(service, mock_task_manager):
policy = _build_policy("pol-nodash", is_active=True, dashboard_ids=[], window_start=time(10, 0), window_end=time(11, 0))
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
service._trigger_validation("pol-nodash")
mock_task_manager.create_task.assert_not_called()
# #endregion test_trigger_validation_no_dashboards
# #region test_trigger_validation_success [C:2] [TYPE Function]
# @BRIEF _trigger_validation spawns tasks for each dashboard in the policy.
def test_trigger_validation_success(service, mock_task_manager):
dash_ids = ["dash-1", "dash-2"]
policy = _build_policy(
"pol-ok", is_active=True, dashboard_ids=dash_ids,
window_start=time(10, 0), window_end=time(11, 0),
environment_id="env-1", provider_id="prov-1",
)
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
db_mock.query.return_value.filter.return_value.first.return_value = policy
with patch("src.core.scheduler.ThrottledSchedulerConfigurator") as mock_cfg:
mock_cfg.calculate_schedule.return_value = [
datetime(2026, 6, 11, 10, 0),
datetime(2026, 6, 11, 11, 0),
]
with patch("src.core.scheduler.asyncio.run_coroutine_threadsafe") as mock_run:
service._trigger_validation("pol-ok")
# Two dashboards → two run_coroutine_threadsafe calls
assert mock_run.call_count == 2
# #endregion test_trigger_validation_success
# #region test_trigger_validation_exception [C:2] [TYPE Function]
# @BRIEF _trigger_validation handles exception gracefully (lines 385-386).
def test_trigger_validation_exception(service, mock_task_manager):
with patch("src.core.scheduler.seed_trace_id"):
with patch("src.core.scheduler.belief_scope") as mock_bs:
mock_bs.return_value.__enter__ = MagicMock()
mock_bs.return_value.__exit__ = MagicMock(return_value=False)
with patch("src.core.scheduler.SessionLocal") as mock_session:
db_mock = MagicMock()
mock_session.return_value = db_mock
# Raise inside the try block (query, not SessionLocal itself)
db_mock.query.side_effect = RuntimeError("DB crash")
# Should not raise
service._trigger_validation("pol-err")
db_mock.close.assert_called_once()
# #endregion test_trigger_validation_exception
# ══════════════════════════════════════════════════════════════
# ThrottledSchedulerConfigurator.calculate_schedule (lines 410-445)
# ══════════════════════════════════════════════════════════════
# #region test_calculate_schedule_empty [C:2] [TYPE Function]
# @BRIEF calculate_schedule returns empty list for empty dashboard_ids (line 417).
def test_calculate_schedule_empty():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0),
window_end=time(11, 0),
dashboard_ids=[],
current_date=date(2026, 6, 11),
)
assert result == []
# #endregion test_calculate_schedule_empty
# #region test_calculate_schedule_single_dashboard [C:2] [TYPE Function]
# @BRIEF calculate_schedule returns single start_dt for one dashboard (line 434).
def test_calculate_schedule_single_dashboard():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0),
window_end=time(11, 0),
dashboard_ids=["dash-1"],
current_date=date(2026, 6, 11),
)
assert len(result) == 1
assert result[0] == datetime(2026, 6, 11, 10, 0)
# #endregion test_calculate_schedule_single_dashboard
# #region test_calculate_schedule_multiple [C:2] [TYPE Function]
# @BRIEF calculate_schedule distributes tasks evenly across the window.
def test_calculate_schedule_multiple():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0),
window_end=time(11, 0),
dashboard_ids=["d1", "d2", "d3"],
current_date=date(2026, 6, 11),
)
assert len(result) == 3
# First at 10:00, last at 11:00, middle at 10:30
assert result[0] == datetime(2026, 6, 11, 10, 0)
assert result[1] == datetime(2026, 6, 11, 10, 30)
assert result[2] == datetime(2026, 6, 11, 11, 0)
# #endregion test_calculate_schedule_multiple
# #region test_calculate_schedule_midnight_crossing [C:2] [TYPE Function]
# @BRIEF calculate_schedule handles window crossing midnight (line 422).
def test_calculate_schedule_midnight_crossing():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(23, 0),
window_end=time(1, 0),
dashboard_ids=["d1", "d2", "d3"],
current_date=date(2026, 6, 11),
)
assert len(result) == 3
# Window: 23:00 → 01:00 next day = 2 hours = 7200s
# Interval: 7200 / 2 = 3600s = 1h
assert result[0] == datetime(2026, 6, 11, 23, 0)
assert result[1] == datetime(2026, 6, 12, 0, 0)
assert result[2] == datetime(2026, 6, 12, 1, 0)
# #endregion test_calculate_schedule_midnight_crossing
# #region test_calculate_schedule_zero_window [C:2] [TYPE Function]
# @BRIEF calculate_schedule returns start_dt repeated for zero-length window (lines 426-429).
def test_calculate_schedule_zero_window():
from src.core.scheduler import ThrottledSchedulerConfigurator
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0),
window_end=time(10, 0),
dashboard_ids=["d1", "d2", "d3"],
current_date=date(2026, 6, 11),
)
assert len(result) == 3
expected = datetime(2026, 6, 11, 10, 0)
assert all(t == expected for t in result)
# #endregion test_calculate_schedule_zero_window
# #region test_calculate_schedule_small_window_warning [C:2] [TYPE Function]
# @BRIEF calculate_schedule warns when interval < 1s (lines 439+).
def test_calculate_schedule_small_window_warning():
from src.core.scheduler import ThrottledSchedulerConfigurator
# 10 dashboards in 5 seconds → interval = 5/9 ≈ 0.56s < 1s
result = ThrottledSchedulerConfigurator.calculate_schedule(
window_start=time(10, 0, 0),
window_end=time(10, 0, 5),
dashboard_ids=[f"d{i}" for i in range(10)],
current_date=date(2026, 6, 11),
)
assert len(result) == 10
# First should be at start, last should be within the window
assert result[0] == datetime(2026, 6, 11, 10, 0, 0)
# #endregion test_calculate_schedule_small_window_warning
# #endregion Test.Scheduler

View File

@@ -0,0 +1,176 @@
# #region Test.AppTimezone [C:3] [TYPE Module] [SEMANTICS test,timezone]
# @BRIEF Unit tests for AppTimezone module — timezone utilities, caching, and localization.
# @RELATION BINDS_TO -> [AppTimezone]
# @TEST_EDGE: invalid_timezone -> validate_timezone returns False for unknown names
# @TEST_EDGE: none_datetime -> localize returns None for None input
# @TEST_EDGE: cache_invalidation -> invalidate clears cache, next call re-reads env
import pytest
from datetime import datetime
from zoneinfo import ZoneInfo
from src.core.timezone import (
_get_default_tz_name,
get_app_timezone,
invalidate_timezone_cache,
validate_timezone,
localize,
now,
format_timezone_offset,
)
@pytest.fixture(autouse=True)
def _clean_cache():
"""Ensure cache is clean before and after each test."""
invalidate_timezone_cache()
yield
invalidate_timezone_cache()
# #region test_get_default_tz_name_default [C:2] [TYPE Function]
# @BRIEF Without APP_TIMEZONE env var, returns "Europe/Moscow".
def test_get_default_tz_name_default(monkeypatch):
monkeypatch.delenv("APP_TIMEZONE", raising=False)
assert _get_default_tz_name() == "Europe/Moscow"
# #endregion test_get_default_tz_name_default
# #region test_get_default_tz_name_custom [C:2] [TYPE Function]
# @BRIEF With APP_TIMEZONE set, returns the custom value.
def test_get_default_tz_name_custom(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "US/Eastern")
assert _get_default_tz_name() == "US/Eastern"
# #endregion test_get_default_tz_name_custom
# #region test_get_app_timezone_returns_zoneinfo [C:2] [TYPE Function]
# @BRIEF Returns a ZoneInfo instance for the configured timezone.
def test_get_app_timezone_returns_zoneinfo(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow")
tz = get_app_timezone()
assert isinstance(tz, ZoneInfo)
assert tz.key == "Europe/Moscow"
# #endregion test_get_app_timezone_returns_zoneinfo
# #region test_get_app_timezone_cached [C:2] [TYPE Function]
# @BRIEF Two consecutive calls return the exact same object (identity).
def test_get_app_timezone_cached(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "Europe/Berlin")
first = get_app_timezone()
second = get_app_timezone()
assert first is second
# #endregion test_get_app_timezone_cached
# #region test_invalidate_and_recreate_cycle [C:2] [TYPE Function]
# @BRIEF After invalidate, next call re-reads env and returns a new object.
def test_invalidate_and_recreate_cycle(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow")
before = get_app_timezone()
assert before.key == "Europe/Moscow"
invalidate_timezone_cache()
monkeypatch.setenv("APP_TIMEZONE", "US/Eastern")
after = get_app_timezone()
assert after.key == "US/Eastern"
assert before is not after
# #endregion test_invalidate_and_recreate_cycle
# #region test_validate_timezone_valid [C:2] [TYPE Function]
# @BRIEF Known IANA timezone names return True.
@pytest.mark.parametrize("tz_name", ["Europe/Moscow", "UTC", "US/Eastern", "Asia/Tokyo"])
def test_validate_timezone_valid(tz_name):
assert validate_timezone(tz_name) is True
# #endregion test_validate_timezone_valid
# #region test_validate_timezone_invalid [C:2] [TYPE Function]
# @BRIEF Unknown or garbage timezone names return False.
@pytest.mark.parametrize("tz_name", ["Invalid/Timezone", "NotATZ", "Foo/Bar/Baz"])
def test_validate_timezone_invalid(tz_name):
assert validate_timezone(tz_name) is False
# #endregion test_validate_timezone_invalid
# #region test_validate_timezone_none [C:2] [TYPE Function]
# @BRIEF None input returns False (TypeError caught internally).
def test_validate_timezone_none():
assert validate_timezone(None) is False
# #endregion test_validate_timezone_none
# #region test_validate_timezone_empty_string_raises [C:2] [TYPE Function]
# @BRIEF BUG: empty string raises ValueError — production code only catches KeyError/TypeError.
def test_validate_timezone_empty_string_raises():
with pytest.raises(ValueError, match="normalized relative paths"):
validate_timezone("")
# #endregion test_validate_timezone_empty_string_raises
# #region test_localize_naive_datetime [C:2] [TYPE Function]
# @BRIEF Naive UTC datetime is treated as UTC and converted to app timezone.
def test_localize_naive_datetime(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow")
invalidate_timezone_cache()
naive_utc = datetime(2024, 6, 15, 12, 0, 0)
result = localize(naive_utc)
# Hardcoded fixture: Moscow is UTC+3 in June (no DST)
assert result.hour == 15
assert result.tzinfo is not None
assert result.tzinfo.key == "Europe/Moscow"
# #endregion test_localize_naive_datetime
# #region test_localize_aware_datetime [C:2] [TYPE Function]
# @BRIEF Aware datetime in a different timezone is converted to app timezone.
def test_localize_aware_datetime(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow")
invalidate_timezone_cache()
aware_utc = datetime(2024, 1, 15, 10, 0, 0, tzinfo=ZoneInfo("UTC"))
result = localize(aware_utc)
# Hardcoded fixture: Moscow is UTC+3 in January (no DST)
assert result.hour == 13
assert result.tzinfo.key == "Europe/Moscow"
# #endregion test_localize_aware_datetime
# #region test_localize_none [C:2] [TYPE Function]
# @BRIEF None input returns None without error.
def test_localize_none():
assert localize(None) is None
# #endregion test_localize_none
# #region test_now_returns_aware [C:2] [TYPE Function]
# @BRIEF now() returns a timezone-aware datetime in the app timezone.
def test_now_returns_aware(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "UTC")
invalidate_timezone_cache()
result = now()
assert result.tzinfo is not None
assert result.tzinfo.key == "UTC"
# #endregion test_now_returns_aware
# #region test_format_timezone_offset [C:2] [TYPE Function]
# @BRIEF Returns formatted UTC offset string like "+03:00".
def test_format_timezone_offset(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "Europe/Moscow")
invalidate_timezone_cache()
offset = format_timezone_offset()
# Hardcoded fixture: Moscow is always UTC+3
assert offset == "+03:00"
# #endregion test_format_timezone_offset
# #region test_format_timezone_offset_utc [C:2] [TYPE Function]
# @BRIEF UTC timezone returns "+00:00".
def test_format_timezone_offset_utc(monkeypatch):
monkeypatch.setenv("APP_TIMEZONE", "UTC")
invalidate_timezone_cache()
offset = format_timezone_offset()
assert offset == "+00:00"
# #endregion test_format_timezone_offset_utc
# #endregion Test.AppTimezone

View File

@@ -27,6 +27,13 @@ import time
from pathlib import Path
import pytest
def pytest_collection_modifyitems(config, items):
if not config.getoption("--run-integration"):
skip_integration = pytest.mark.skip(reason="use --run-integration to run")
for item in items:
item.add_marker(skip_integration)
import requests
from sqlalchemy import create_engine, event, text
from sqlalchemy.orm import Session, sessionmaker

View File

@@ -0,0 +1,190 @@
# #region Test.FilterStateModels [C:3] [TYPE Module] [SEMANTICS test,filter_state,models]
# @BRIEF Verify Pydantic filter-state models — creation, defaults, methods, and merge logic.
# @RELATION BINDS_TO -> [FilterStateModels]
# @TEST_EDGE: missing_filter_id -> get_extra_form_data returns empty dict
# @TEST_EDGE: empty_dataMask -> has_filters returns False
# @TEST_EDGE: append_non_list_fallback -> append key with non-list values falls through to override
import pytest
from src.models.filter_state import (
DashboardURLFilterExtraction,
ExtraFormDataMerge,
FilterState,
NativeFilterDataMask,
ParsedNativeFilters,
)
class TestFilterStateModel:
"""Verify FilterState creation and defaults."""
# #region test_filter_state_defaults [C:2] [TYPE Function]
# @BRIEF FilterState with no args produces empty dicts for all three fields.
def test_filter_state_defaults(self):
fs = FilterState()
assert fs.extraFormData == {}
assert fs.filterState == {}
assert fs.ownState == {}
# #endregion test_filter_state_defaults
# #region test_filter_state_all_fields [C:2] [TYPE Function]
# @BRIEF FilterState with explicit values preserves every field.
def test_filter_state_all_fields(self):
fs = FilterState(
extraFormData={"time_range": "2024 : 2025"},
filterState={"value": ["admin"]},
ownState={"owner": "user_1"},
)
assert fs.extraFormData == {"time_range": "2024 : 2025"}
assert fs.filterState == {"value": ["admin"]}
assert fs.ownState == {"owner": "user_1"}
# #endregion test_filter_state_all_fields
class TestNativeFilterDataMaskModel:
"""Verify NativeFilterDataMask container methods."""
# #region test_get_filter_ids [C:2] [TYPE Function]
# @BRIEF get_filter_ids returns all keys from the filters dict.
def test_get_filter_ids(self):
mask = NativeFilterDataMask(filters={
"NATIVE_FILTER_ABC": FilterState(extraFormData={"col": "a"}),
"NATIVE_FILTER_XYZ": FilterState(extraFormData={"col": "b"}),
})
assert mask.get_filter_ids() == ["NATIVE_FILTER_ABC", "NATIVE_FILTER_XYZ"]
# #endregion test_get_filter_ids
# #region test_get_extra_form_data_existing [C:2] [TYPE Function]
# @BRIEF get_extra_form_data returns extraFormData for a known filter ID.
def test_get_extra_form_data_existing(self):
mask = NativeFilterDataMask(filters={
"NATIVE_FILTER_1": FilterState(extraFormData={"time_range": "Last week"}),
})
assert mask.get_extra_form_data("NATIVE_FILTER_1") == {"time_range": "Last week"}
# #endregion test_get_extra_form_data_existing
# #region test_get_extra_form_data_missing [C:2] [TYPE Function]
# @BRIEF get_extra_form_data returns empty dict for unknown filter ID.
def test_get_extra_form_data_missing(self):
mask = NativeFilterDataMask(filters={
"NATIVE_FILTER_1": FilterState(extraFormData={"col": "x"}),
})
assert mask.get_extra_form_data("NONEXISTENT_ID") == {}
# #endregion test_get_extra_form_data_missing
class TestParsedNativeFiltersModel:
"""Verify ParsedNativeFilters query methods."""
# #region test_has_filters_true [C:2] [TYPE Function]
# @BRIEF has_filters returns True when dataMask is non-empty.
def test_has_filters_true(self):
parsed = ParsedNativeFilters(dataMask={"f1": {}, "f2": {}})
assert parsed.has_filters() is True
# #endregion test_has_filters_true
# #region test_has_filters_false [C:2] [TYPE Function]
# @BRIEF has_filters returns False when dataMask is empty.
def test_has_filters_false(self):
parsed = ParsedNativeFilters()
assert parsed.has_filters() is False
# #endregion test_has_filters_false
# #region test_get_filter_count [C:2] [TYPE Function]
# @BRIEF get_filter_count returns the number of entries in dataMask.
def test_get_filter_count(self):
parsed = ParsedNativeFilters(dataMask={"a": {}, "b": {}, "c": {}})
assert parsed.get_filter_count() == 3
# #endregion test_get_filter_count
class TestDashboardURLFilterExtractionModel:
"""Verify DashboardURLFilterExtraction creation and defaults."""
# #region test_dashboard_url_extraction_all_fields [C:2] [TYPE Function]
# @BRIEF All fields populated are preserved correctly.
def test_dashboard_url_extraction_all_fields(self):
filters = ParsedNativeFilters(dataMask={"f1": {}})
extraction = DashboardURLFilterExtraction(
url="https://superset/dashboard/42?native_filters=key1",
dashboard_id="42",
filter_type="permalink",
filters=filters,
success=False,
error="partial extraction",
)
assert extraction.url == "https://superset/dashboard/42?native_filters=key1"
assert extraction.dashboard_id == "42"
assert extraction.filter_type == "permalink"
assert extraction.filters is filters
assert extraction.success is False
assert extraction.error == "partial extraction"
# #endregion test_dashboard_url_extraction_all_fields
# #region test_dashboard_url_extraction_default_success [C:2] [TYPE Function]
# @BRIEF success defaults to True when not specified.
def test_dashboard_url_extraction_default_success(self):
extraction = DashboardURLFilterExtraction(url="https://superset/dashboard/1")
assert extraction.success is True
assert extraction.error is None
assert extraction.dashboard_id is None
# #endregion test_dashboard_url_extraction_default_success
class TestExtraFormDataMergeModel:
"""Verify ExtraFormDataMerge configuration and merge logic."""
# #region test_merge_default_config [C:2] [TYPE Function]
# @BRIEF Default append_keys and override_keys match the documented lists.
def test_merge_default_config(self):
cfg = ExtraFormDataMerge()
assert cfg.append_keys == ["filters", "extras", "columns", "metrics"]
assert cfg.override_keys == ["time_range", "time_grain_sqla", "time_column", "granularity"]
# #endregion test_merge_default_config
# #region test_merge_override_keys [C:2] [TYPE Function]
# @BRIEF Override keys replace original values entirely.
def test_merge_override_keys(self):
cfg = ExtraFormDataMerge()
original = {"time_range": "2023 : 2024", "time_grain_sqla": "DAY"}
new = {"time_range": "2024 : 2025", "time_grain_sqla": "MONTH"}
result = cfg.merge(original, new)
assert result["time_range"] == "2024 : 2025"
assert result["time_grain_sqla"] == "MONTH"
# #endregion test_merge_override_keys
# #region test_merge_append_keys [C:2] [TYPE Function]
# @BRIEF Append keys concatenate lists from original and new.
def test_merge_append_keys(self):
cfg = ExtraFormDataMerge()
original = {"filters": [{"col": "a"}], "columns": ["x"]}
new = {"filters": [{"col": "b"}], "columns": ["y", "z"]}
result = cfg.merge(original, new)
assert result["filters"] == [{"col": "a"}, {"col": "b"}]
assert result["columns"] == ["x", "y", "z"]
# #endregion test_merge_append_keys
# #region test_merge_non_special_keys [C:2] [TYPE Function]
# @BRIEF Keys not in override or append lists fall through — new replaces original.
def test_merge_non_special_keys(self):
cfg = ExtraFormDataMerge()
original = {"custom_key": "old_value", "another": 1}
new = {"custom_key": "new_value", "brand_new": True}
result = cfg.merge(original, new)
assert result["custom_key"] == "new_value"
assert result["another"] == 1
assert result["brand_new"] is True
# #endregion test_merge_non_special_keys
# #region test_merge_append_non_list_fallback [C:2] [TYPE Function]
# @BRIEF Append key with non-list values falls through to simple override.
def test_merge_append_non_list_fallback(self):
cfg = ExtraFormDataMerge()
original = {"filters": "not_a_list"}
new = {"filters": ["real_filter"]}
result = cfg.merge(original, new)
assert result["filters"] == ["real_filter"]
# #endregion test_merge_append_non_list_fallback
# #endregion Test.FilterStateModels

View File

@@ -0,0 +1,339 @@
# #region Test.Git.Branch [C:3] [TYPE Module] [SEMANTICS test,git,branch,checkout,commit,gitflow]
# @BRIEF Tests for GitServiceBranchMixin — gitflow bootstrap, list/create/checkout branches, commit changes.
# @RELATION BINDS_TO -> [GitServiceBranchMixin]
# @TEST_EDGE: empty_repo -> initial commit created for branching
# @TEST_EDGE: no_commits -> gitflow bootstrap skipped
# @TEST_EDGE: checkout_local_changes -> HTTPException 409
# @TEST_EDGE: checkout_generic_error -> HTTPException 500
# @TEST_EDGE: commit_dirty -> stages all and commits
# @TEST_EDGE: commit_clean -> no commit when not dirty
# @TEST_EDGE: commit_specific_files -> only listed files staged
# @TEST_EDGE: list_branches_filters_tags -> refs/tags/ excluded
# @TEST_EDGE: create_branch_from_missing -> falls back to HEAD
import contextlib
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, patch, call, PropertyMock
from fastapi import HTTPException
from git.exc import GitCommandError
from src.services.git._branch import GitServiceBranchMixin
class TestableGitBranch(GitServiceBranchMixin):
"""Concrete test class providing _locked and get_repo stubs."""
def __init__(self, mock_repo=None):
self._mock_repo = mock_repo or MagicMock()
async def get_repo(self, dashboard_id):
return self._mock_repo
def _locked(self, dashboard_id):
@contextlib.contextmanager
def _lock():
yield
return _lock()
# ── _ensure_gitflow_branches ──
class TestEnsureGitflowBranches:
"""_ensure_gitflow_branches — main/dev/preprod bootstrap."""
# #region test_gitflow_no_commits [C:2] [TYPE Function]
def test_gitflow_no_commits(self):
"""Empty repo (no commits) → skip bootstrap, no error."""
repo = MagicMock()
type(repo.head).commit = property(lambda self: (_ for _ in StopIteration()).throw(ValueError("no commits")))
repo.heads = []
svc = TestableGitBranch()
svc._ensure_gitflow_branches(repo, 1)
repo.create_head.assert_not_called()
# #endregion test_gitflow_no_commits
# #region test_gitflow_creates_missing_branches [C:2] [TYPE Function]
def test_gitflow_creates_missing_branches(self):
"""Missing dev/preprod → created from main."""
repo = MagicMock()
main_head = MagicMock()
main_head.name = "main"
main_head.commit = MagicMock()
repo.heads = [main_head]
repo.head.commit = MagicMock()
origin = MagicMock()
origin.refs = []
repo.remote.return_value = origin
svc = TestableGitBranch()
svc._ensure_gitflow_branches(repo, 1)
created_names = [c.args[0] for c in repo.create_head.call_args_list]
assert "dev" in created_names
assert "preprod" in created_names
# #endregion test_gitflow_creates_missing_branches
# #region test_gitflow_no_origin [C:2] [TYPE Function]
def test_gitflow_no_origin(self):
"""No origin remote → skip remote push, no error."""
repo = MagicMock()
main_head = MagicMock()
main_head.name = "main"
main_head.commit = MagicMock()
repo.heads = [main_head]
repo.head.commit = MagicMock()
repo.remote.side_effect = ValueError("no origin")
svc = TestableGitBranch()
# Should not raise
svc._ensure_gitflow_branches(repo, 1)
# #endregion test_gitflow_no_origin
# #region test_gitflow_already_on_dev [C:2] [TYPE Function]
def test_gitflow_already_on_dev(self):
"""Already on dev → no checkout call."""
repo = MagicMock()
main_head = MagicMock()
main_head.name = "main"
main_head.commit = MagicMock()
dev_head = MagicMock()
dev_head.name = "dev"
dev_head.commit = MagicMock()
preprod_head = MagicMock()
preprod_head.name = "preprod"
preprod_head.commit = MagicMock()
repo.heads = [main_head, dev_head, preprod_head]
repo.head.commit = MagicMock()
repo.active_branch.name = "dev"
origin = MagicMock()
origin.refs = [MagicMock(remote_head="main"), MagicMock(remote_head="dev"), MagicMock(remote_head="preprod")]
repo.remote.return_value = origin
svc = TestableGitBranch()
svc._ensure_gitflow_branches(repo, 1)
repo.git.checkout.assert_not_called()
# #endregion test_gitflow_already_on_dev
# ── list_branches ──
class TestListBranches:
"""list_branches — branch listing with tag filtering."""
# #region test_list_branches_basic [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_list_branches_basic(self):
"""Returns branch dicts with name, commit_hash, is_remote."""
repo = MagicMock()
ref1 = MagicMock()
ref1.name = "refs/heads/dev"
ref1.commit.hexsha = "abc123"
ref1.commit.committed_date = 1700000000
ref1.is_remote.return_value = False
repo.refs = [ref1]
repo.active_branch.name = "dev"
svc = TestableGitBranch(repo)
result = await svc.list_branches(1)
assert any(b["name"] == "dev" for b in result)
assert result[0]["commit_hash"] == "abc123"
# #endregion test_list_branches_basic
# #region test_list_branches_filters_tags [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_list_branches_filters_tags(self):
"""refs/tags/ entries excluded from result."""
repo = MagicMock()
tag_ref = MagicMock()
tag_ref.name = "refs/tags/v1.0"
tag_ref.commit.hexsha = "tag123"
tag_ref.commit.committed_date = 1700000000
branch_ref = MagicMock()
branch_ref.name = "refs/heads/main"
branch_ref.commit.hexsha = "main123"
branch_ref.commit.committed_date = 1700000000
branch_ref.is_remote.return_value = False
repo.refs = [tag_ref, branch_ref]
repo.active_branch.name = "main"
svc = TestableGitBranch(repo)
result = await svc.list_branches(1)
names = [b["name"] for b in result]
assert "v1.0" not in names
assert "main" in names
# #endregion test_list_branches_filters_tags
# #region test_list_branches_deduplicates [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_list_branches_deduplicates(self):
"""Same name from heads and remotes → only one entry."""
repo = MagicMock()
local_ref = MagicMock()
local_ref.name = "refs/heads/dev"
local_ref.commit.hexsha = "abc"
local_ref.commit.committed_date = 1700000000
local_ref.is_remote.return_value = False
remote_ref = MagicMock()
remote_ref.name = "refs/remotes/origin/dev"
remote_ref.commit.hexsha = "abc"
remote_ref.commit.committed_date = 1700000000
remote_ref.is_remote.return_value = True
repo.refs = [local_ref, remote_ref]
repo.active_branch.name = "dev"
svc = TestableGitBranch(repo)
result = await svc.list_branches(1)
dev_entries = [b for b in result if b["name"] == "dev"]
assert len(dev_entries) == 1
# #endregion test_list_branches_deduplicates
# #region test_list_branches_detached_head [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_list_branches_detached_head(self):
"""Detached HEAD → fallback 'dev' branch added if empty."""
repo = MagicMock()
repo.refs = []
type(repo.active_branch).name = PropertyMock(side_effect=TypeError("detached HEAD"))
svc = TestableGitBranch(repo)
result = await svc.list_branches(1)
assert any(b["name"] == "dev" for b in result)
# #endregion test_list_branches_detached_head
# ── create_branch ──
class TestCreateBranch:
"""create_branch — new branch from existing."""
# #region test_create_branch_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_create_branch_success(self):
"""Branch created from specified source."""
repo = MagicMock()
repo.heads = [MagicMock()]
repo.remotes = [MagicMock()]
repo.commit.return_value = MagicMock()
new_branch = MagicMock()
repo.create_head.return_value = new_branch
svc = TestableGitBranch(repo)
result = await svc.create_branch(1, "feature-x", "main")
repo.create_head.assert_called_with("feature-x", "main")
assert result == new_branch
# #endregion test_create_branch_success
# #region test_create_branch_empty_repo [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_create_branch_empty_repo(self):
"""Empty repo → initial commit created, then branch."""
repo = MagicMock()
repo.heads = []
repo.remotes = []
repo.working_dir = "/tmp/repo"
new_branch = MagicMock()
repo.create_head.return_value = new_branch
svc = TestableGitBranch(repo)
with patch("os.path.exists", return_value=False), \
patch("builtins.open", MagicMock()):
result = await svc.create_branch(1, "feature", "main")
repo.index.add.assert_called_with(["README.md"])
repo.index.commit.assert_called_with("Initial commit")
# #endregion test_create_branch_empty_repo
# #region test_create_branch_missing_source [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_create_branch_missing_source(self):
"""Source branch not found → falls back to repo.head."""
repo = MagicMock()
repo.heads = [MagicMock()]
repo.remotes = [MagicMock()]
repo.commit.side_effect = Exception("not found")
repo.head = MagicMock()
repo.create_head.return_value = MagicMock()
svc = TestableGitBranch(repo)
await svc.create_branch(1, "feature", "nonexistent")
repo.create_head.assert_called_with("feature", repo.head)
# #endregion test_create_branch_missing_source
# ── checkout_branch ──
class TestCheckoutBranch:
"""checkout_branch — switch active branch."""
# #region test_checkout_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_checkout_success(self):
"""Successful checkout → git.checkout called."""
repo = MagicMock()
svc = TestableGitBranch(repo)
await svc.checkout_branch(1, "main")
repo.git.checkout.assert_called_with("main")
# #endregion test_checkout_success
# #region test_checkout_local_changes [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_checkout_local_changes(self):
"""Local changes conflict → HTTPException 409."""
repo = MagicMock()
error = GitCommandError("checkout", "error")
error.stderr = "error: Your local changes to the following files would be overwritten by checkout:\n\tconfig.yaml"
repo.git.checkout.side_effect = error
svc = TestableGitBranch(repo)
with pytest.raises(HTTPException) as exc_info:
await svc.checkout_branch(1, "main")
assert exc_info.value.status_code == 409
detail = exc_info.value.detail
assert detail["error_code"] == "GIT_CHECKOUT_LOCAL_CHANGES"
# #endregion test_checkout_local_changes
# #region test_checkout_generic_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_checkout_generic_error(self):
"""Other GitCommandError → HTTPException 500."""
repo = MagicMock()
repo.git.checkout.side_effect = GitCommandError("checkout", "fatal: bad ref")
svc = TestableGitBranch(repo)
with pytest.raises(HTTPException) as exc_info:
await svc.checkout_branch(1, "bad-branch")
assert exc_info.value.status_code == 500
# #endregion test_checkout_generic_error
# ── commit_changes ──
class TestCommitChanges:
"""commit_changes — stage and commit."""
# #region test_commit_dirty_all [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_commit_dirty_all(self):
"""Dirty repo, no files → stage all and commit."""
repo = MagicMock()
repo.is_dirty.return_value = True
svc = TestableGitBranch(repo)
await svc.commit_changes(1, "update config")
repo.git.add.assert_called_with(A=True)
repo.index.commit.assert_called_with("update config")
# #endregion test_commit_dirty_all
# #region test_commit_clean [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_commit_clean(self):
"""Clean repo, no files → no commit."""
repo = MagicMock()
repo.is_dirty.return_value = False
svc = TestableGitBranch(repo)
await svc.commit_changes(1, "nothing")
repo.index.commit.assert_not_called()
# #endregion test_commit_clean
# #region test_commit_specific_files [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_commit_specific_files(self):
"""Files list → only those files staged."""
repo = MagicMock()
repo.is_dirty.return_value = False
svc = TestableGitBranch(repo)
await svc.commit_changes(1, "fix", files=["a.yaml", "b.yaml"])
repo.index.add.assert_called_with(["a.yaml", "b.yaml"])
repo.index.commit.assert_called_with("fix")
# #endregion test_commit_specific_files
# #endregion Test.Git.Branch

View File

@@ -0,0 +1,480 @@
# #region Test.Git.Gitea [C:3] [TYPE Module] [SEMANTICS test,git,gitea,api,http,pull-request]
# @BRIEF Tests for GitServiceGiteaMixin — connection testing, headers, API requests, user resolution, repo CRUD, PR creation with fallback.
# @RELATION BINDS_TO -> [GitServiceGiteaMixin]
# @TEST_EDGE: local_url -> returns True without API call
# @TEST_EDGE: invalid_protocol -> returns False
# @TEST_EDGE: empty_pat -> returns False
# @TEST_EDGE: api_error -> HTTPException with status code
# @TEST_EDGE: network_error -> HTTPException 503
# @TEST_EDGE: empty_pat_headers -> HTTPException 400
# @TEST_EDGE: create_repo_conflict -> fetches existing repo
# @TEST_EDGE: pr_404_fallback -> retries with derived URL
# @TEST_EDGE: pr_404_branch_missing -> HTTPException 400 with detail
# @TEST_EDGE: delete_missing_owner -> HTTPException 400
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import HTTPException
from src.models.git import GitProvider
from src.services.git._gitea import GitServiceGiteaMixin
class TestableGitGitea(GitServiceGiteaMixin):
"""Concrete test class providing _http_client and URL mixin stubs."""
def __init__(self):
self._http_client = AsyncMock()
def _normalize_git_server_url(self, raw_url):
normalized = (raw_url or "").strip()
if not normalized:
raise HTTPException(status_code=400, detail="Git server URL is required")
return normalized.rstrip("/")
def _parse_remote_repo_identity(self, remote_url):
from urllib.parse import urlparse
normalized = str(remote_url or "").strip()
if not normalized:
raise HTTPException(status_code=400, detail="empty")
if normalized.startswith("git@"):
path = normalized.split(":", 1)[1] if ":" in normalized else ""
else:
parsed = urlparse(normalized)
path = parsed.path or ""
path = path.strip("/")
if path.endswith(".git"):
path = path[:-4]
parts = [s for s in path.split("/") if s]
if len(parts) < 2:
raise HTTPException(status_code=400, detail="Cannot parse")
owner, repo = parts[0], parts[-1]
namespace = "/".join(parts[:-1])
return {"owner": owner, "repo": repo, "namespace": namespace, "full_name": f"{namespace}/{repo}"}
def _derive_server_url_from_remote(self, remote_url):
from urllib.parse import urlparse
normalized = str(remote_url or "").strip()
if not normalized or normalized.startswith("git@"):
return None
parsed = urlparse(normalized)
if parsed.scheme not in {"http", "https"}:
return None
if not parsed.hostname:
return None
netloc = parsed.hostname
if parsed.port:
netloc = f"{netloc}:{parsed.port}"
return f"{parsed.scheme}://{netloc}"
# ── test_connection ──
class TestTestConnection:
"""test_connection — provider connectivity check."""
# #region test_connection_local_url [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_local_url(self):
"""Local/localhost URL → True without API call."""
svc = TestableGitGitea()
result = await svc.test_connection(GitProvider.GITEA, "http://gitea.local:3000", "pat")
assert result is True
svc._http_client.get.assert_not_called()
# #endregion test_connection_local_url
# #region test_connection_invalid_protocol [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_invalid_protocol(self):
"""Non-HTTP URL → False."""
svc = TestableGitGitea()
result = await svc.test_connection(GitProvider.GITEA, "ftp://host.com", "pat")
assert result is False
# #endregion test_connection_invalid_protocol
# #region test_connection_empty_pat [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_empty_pat(self):
"""Empty PAT → False."""
svc = TestableGitGitea()
result = await svc.test_connection(GitProvider.GITEA, "https://gitea.com", "")
assert result is False
# #endregion test_connection_empty_pat
# #region test_connection_gitea_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_gitea_success(self):
"""Gitea 200 → True."""
svc = TestableGitGitea()
resp = MagicMock()
resp.status_code = 200
svc._http_client.get.return_value = resp
result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "valid-pat")
assert result is True
svc._http_client.get.assert_called_once()
call_url = svc._http_client.get.call_args[0][0]
assert "/api/v1/user" in call_url
# #endregion test_connection_gitea_success
# #region test_connection_github_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_github_success(self):
"""GitHub 200 → True."""
svc = TestableGitGitea()
resp = MagicMock()
resp.status_code = 200
svc._http_client.get.return_value = resp
result = await svc.test_connection(GitProvider.GITHUB, "https://github.com", "valid-pat")
assert result is True
call_url = svc._http_client.get.call_args[0][0]
assert "api.github.com" in call_url
# #endregion test_connection_github_success
# #region test_connection_gitlab_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_gitlab_success(self):
"""GitLab 200 → True."""
svc = TestableGitGitea()
resp = MagicMock()
resp.status_code = 200
svc._http_client.get.return_value = resp
result = await svc.test_connection(GitProvider.GITLAB, "https://gitlab.com", "valid-pat")
assert result is True
call_url = svc._http_client.get.call_args[0][0]
assert "/api/v4/user" in call_url
# #endregion test_connection_gitlab_success
# #region test_connection_failure_status [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_failure_status(self):
"""Non-200 response → False."""
svc = TestableGitGitea()
resp = MagicMock()
resp.status_code = 401
svc._http_client.get.return_value = resp
result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "bad-pat")
assert result is False
# #endregion test_connection_failure_status
# #region test_connection_network_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_network_error(self):
"""Network exception → False."""
svc = TestableGitGitea()
svc._http_client.get.side_effect = Exception("connection refused")
result = await svc.test_connection(GitProvider.GITEA, "https://gitea.example.com", "pat")
assert result is False
# #endregion test_connection_network_error
# #region test_connection_unknown_provider [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_connection_unknown_provider(self):
"""Unknown provider → False."""
svc = TestableGitGitea()
result = await svc.test_connection("BITBUCKET", "https://bitbucket.org", "pat")
assert result is False
# #endregion test_connection_unknown_provider
# ── _gitea_headers ──
class TestGiteaHeaders:
"""_gitea_headers — authorization header construction."""
# #region test_headers_valid_pat [C:2] [TYPE Function]
def test_headers_valid_pat(self):
"""Valid PAT → Authorization token header."""
svc = TestableGitGitea()
headers = svc._gitea_headers("my-token")
assert headers["Authorization"] == "token my-token"
assert headers["Content-Type"] == "application/json"
# #endregion test_headers_valid_pat
# #region test_headers_empty_pat [C:2] [TYPE Function]
def test_headers_empty_pat(self):
"""Empty PAT → HTTPException 400."""
svc = TestableGitGitea()
with pytest.raises(HTTPException, match="PAT is required"):
svc._gitea_headers("")
# #endregion test_headers_empty_pat
# ── _gitea_request ──
class TestGiteaRequest:
"""_gitea_request — HTTP request wrapper with error mapping."""
# #region test_request_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_request_success(self):
"""200 response → JSON payload returned."""
svc = TestableGitGitea()
resp = MagicMock()
resp.status_code = 200
resp.json.return_value = {"login": "admin"}
svc._http_client.request.return_value = resp
result = await svc._gitea_request("GET", "https://gitea.com", "pat", "/user")
assert result == {"login": "admin"}
# #endregion test_request_success
# #region test_request_204_no_content [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_request_204_no_content(self):
"""204 → returns None."""
svc = TestableGitGitea()
resp = MagicMock()
resp.status_code = 204
svc._http_client.request.return_value = resp
result = await svc._gitea_request("DELETE", "https://gitea.com", "pat", "/repos/o/r")
assert result is None
# #endregion test_request_204_no_content
# #region test_request_api_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_request_api_error(self):
"""4xx/5xx → HTTPException with detail."""
svc = TestableGitGitea()
resp = MagicMock()
resp.status_code = 404
resp.text = "Not Found"
resp.json.return_value = {"message": "repository not found"}
svc._http_client.request.return_value = resp
with pytest.raises(HTTPException, match="repository not found"):
await svc._gitea_request("GET", "https://gitea.com", "pat", "/repos/o/r")
# #endregion test_request_api_error
# #region test_request_network_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_request_network_error(self):
"""Network exception → HTTPException 503."""
svc = TestableGitGitea()
svc._http_client.request.side_effect = Exception("connection refused")
with pytest.raises(HTTPException, match="unavailable"):
await svc._gitea_request("GET", "https://gitea.com", "pat", "/user")
# #endregion test_request_network_error
# ── get_gitea_current_user ──
class TestGetGiteaCurrentUser:
"""get_gitea_current_user — resolve authenticated username."""
# #region test_current_user_login [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_current_user_login(self):
"""Returns 'login' field from API response."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"login": "admin"}):
result = await svc.get_gitea_current_user("https://gitea.com", "pat")
assert result == "admin"
# #endregion test_current_user_login
# #region test_current_user_username_fallback [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_current_user_username_fallback(self):
"""Falls back to 'username' field."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"username": "bob"}):
result = await svc.get_gitea_current_user("https://gitea.com", "pat")
assert result == "bob"
# #endregion test_current_user_username_fallback
# #region test_current_user_missing [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_current_user_missing(self):
"""No login/username → HTTPException 500."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={}):
with pytest.raises(HTTPException, match="Failed to resolve"):
await svc.get_gitea_current_user("https://gitea.com", "pat")
# #endregion test_current_user_missing
# ── list_gitea_repositories ──
class TestListGiteaRepositories:
"""list_gitea_repositories — list user repos."""
# #region test_list_repos_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_list_repos_success(self):
"""Returns list from API."""
svc = TestableGitGitea()
repos = [{"name": "repo1"}, {"name": "repo2"}]
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=repos):
result = await svc.list_gitea_repositories("https://gitea.com", "pat")
assert len(result) == 2
# #endregion test_list_repos_success
# #region test_list_repos_non_list [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_list_repos_non_list(self):
"""Non-list response → returns []."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"error": "bad"}):
result = await svc.list_gitea_repositories("https://gitea.com", "pat")
assert result == []
# #endregion test_list_repos_non_list
# ── create_gitea_repository ──
class TestCreateGiteaRepository:
"""create_gitea_repository — create or fetch existing."""
# #region test_create_repo_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_create_repo_success(self):
"""200 → returns created repo dict."""
svc = TestableGitGitea()
created = {"name": "new-repo", "clone_url": "https://gitea.com/user/new-repo.git"}
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=created):
result = await svc.create_gitea_repository("https://gitea.com", "pat", "new-repo")
assert result["name"] == "new-repo"
# #endregion test_create_repo_success
# #region test_create_repo_conflict_fetches_existing [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_create_repo_conflict_fetches_existing(self):
"""409 → fetches existing repo by name."""
svc = TestableGitGitea()
existing = {"name": "existing-repo", "clone_url": "https://gitea.com/user/existing-repo.git"}
call_count = 0
async def mock_request(method, url, pat, endpoint, payload=None):
nonlocal call_count
call_count += 1
if method == "POST":
raise HTTPException(status_code=409, detail="conflict")
if endpoint.startswith("/user"):
return {"login": "user"}
return existing
with patch.object(svc, "_gitea_request", side_effect=mock_request):
result = await svc.create_gitea_repository("https://gitea.com", "pat", "existing-repo")
assert result["name"] == "existing-repo"
# #endregion test_create_repo_conflict_fetches_existing
# #region test_create_repo_unexpected_response [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_create_repo_unexpected_response(self):
"""Non-dict response → HTTPException 500."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value="not a dict"):
with pytest.raises(HTTPException, match="Unexpected"):
await svc.create_gitea_repository("https://gitea.com", "pat", "repo")
# #endregion test_create_repo_unexpected_response
# ── delete_gitea_repository ──
class TestDeleteGiteaRepository:
"""delete_gitea_repository — delete remote repo."""
# #region test_delete_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_success(self):
"""Valid owner/repo → DELETE request made."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=None) as mock_req:
await svc.delete_gitea_repository("https://gitea.com", "pat", "owner", "repo")
mock_req.assert_called_once()
assert mock_req.call_args[0][0] == "DELETE"
# #endregion test_delete_success
# #region test_delete_missing_owner [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_delete_missing_owner(self):
"""Empty owner → HTTPException 400."""
svc = TestableGitGitea()
with pytest.raises(HTTPException, match="required"):
await svc.delete_gitea_repository("https://gitea.com", "pat", "", "repo")
# #endregion test_delete_missing_owner
# ── _gitea_branch_exists ──
class TestGiteaBranchExists:
"""_gitea_branch_exists — check branch existence."""
# #region test_branch_exists_true [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_branch_exists_true(self):
"""200 → True."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value={"name": "dev"}):
result = await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "dev")
assert result is True
# #endregion test_branch_exists_true
# #region test_branch_exists_404 [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_branch_exists_404(self):
"""404 → False."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", side_effect=HTTPException(status_code=404)):
result = await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "missing")
assert result is False
# #endregion test_branch_exists_404
# #region test_branch_exists_empty_params [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_branch_exists_empty_params(self):
"""Empty owner/repo/branch → False."""
svc = TestableGitGitea()
assert await svc._gitea_branch_exists("https://gitea.com", "pat", "", "repo", "dev") is False
assert await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "", "dev") is False
assert await svc._gitea_branch_exists("https://gitea.com", "pat", "owner", "repo", "") is False
# #endregion test_branch_exists_empty_params
# ── create_gitea_pull_request ──
class TestCreateGiteaPullRequest:
"""create_gitea_pull_request — PR creation with fallback."""
# #region test_pr_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pr_success(self):
"""Successful PR → normalized metadata."""
svc = TestableGitGitea()
pr_data = {"number": 42, "html_url": "https://gitea.com/o/r/pulls/42", "state": "open"}
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value=pr_data):
result = await svc.create_gitea_pull_request(
"https://gitea.com", "pat", "https://gitea.com/owner/repo.git",
"feature", "main", "Add feature",
)
assert result["id"] == 42
assert result["status"] == "open"
assert "pulls/42" in result["url"]
# #endregion test_pr_success
# #region test_pr_404_non_retryable [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pr_404_non_retryable(self):
"""404 with no fallback URL → raises with branch detail."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", side_effect=HTTPException(status_code=404, detail="not found")), \
patch.object(svc, "_build_gitea_pr_404_detail", new_callable=AsyncMock, return_value=None):
with pytest.raises(HTTPException, match="not found"):
await svc.create_gitea_pull_request(
"https://gitea.com", "pat", "https://gitea.com/owner/repo.git",
"feature", "main", "PR title",
)
# #endregion test_pr_404_non_retryable
# #region test_pr_unexpected_response [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pr_unexpected_response(self):
"""Non-dict response → HTTPException 500."""
svc = TestableGitGitea()
with patch.object(svc, "_gitea_request", new_callable=AsyncMock, return_value="not a dict"):
with pytest.raises(HTTPException, match="Unexpected"):
await svc.create_gitea_pull_request(
"https://gitea.com", "pat", "https://gitea.com/owner/repo.git",
"feature", "main", "PR title",
)
# #endregion test_pr_unexpected_response
# #endregion Test.Git.Gitea

View File

@@ -0,0 +1,435 @@
# #region Test.Git.Merge [C:3] [TYPE Module] [SEMANTICS test,git,merge,conflict,resolution]
# @BRIEF Tests for GitServiceMergeMixin — merge status, conflict listing, resolution, abort, continue, direct promote.
# @RELATION BINDS_TO -> [GitServiceMergeMixin]
# @TEST_EDGE: no_merge_in_progress -> has_unfinished_merge=false
# @TEST_EDGE: unfinished_merge -> MERGE_HEAD present, payload built
# @TEST_EDGE: conflict_list -> unmerged blobs parsed with mine/theirs
# @TEST_EDGE: resolve_mine -> checkout --ours called
# @TEST_EDGE: resolve_theirs -> checkout --theirs called
# @TEST_EDGE: resolve_manual -> file written with content
# @TEST_EDGE: resolve_invalid_strategy -> HTTPException 400
# @TEST_EDGE: resolve_empty_filepath -> HTTPException 400
# @TEST_EDGE: abort_no_merge -> returns no_merge_in_progress
# @TEST_EDGE: continue_with_conflicts -> HTTPException 409
# @TEST_EDGE: promote_same_branch -> HTTPException 400
# @TEST_EDGE: promote_missing_branch -> HTTPException 404
import contextlib
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import AsyncMock, MagicMock, patch, mock_open
from fastapi import HTTPException
from src.services.git._merge import GitServiceMergeMixin
class TestableGitMerge(GitServiceMergeMixin):
"""Concrete test class providing _locked stub. get_repo must be set per-test."""
def __init__(self, mock_repo=None):
self._mock_repo = mock_repo or MagicMock()
def _locked(self, dashboard_id):
@contextlib.contextmanager
def _lock():
yield
return _lock()
# ── _read_blob_text ──
class TestReadBlobText:
"""_read_blob_text — blob content decoding."""
# #region test_blob_none [C:2] [TYPE Function]
def test_blob_none(self):
"""None blob returns empty string."""
svc = TestableGitMerge()
assert svc._read_blob_text(None) == ""
# #endregion test_blob_none
# #region test_blob_valid [C:2] [TYPE Function]
def test_blob_valid(self):
"""Valid blob decoded as UTF-8."""
svc = TestableGitMerge()
blob = MagicMock()
blob.data_stream.read.return_value = b"hello world"
assert svc._read_blob_text(blob) == "hello world"
# #endregion test_blob_valid
# #region test_blob_decode_error [C:2] [TYPE Function]
def test_blob_decode_error(self):
"""Decode exception returns empty string."""
svc = TestableGitMerge()
blob = MagicMock()
blob.data_stream.read.side_effect = Exception("binary data")
assert svc._read_blob_text(blob) == ""
# #endregion test_blob_decode_error
# ── _get_unmerged_file_paths ──
class TestGetUnmergedFilePaths:
"""_get_unmerged_file_paths — list conflicted files."""
# #region test_unmerged_files [C:2] [TYPE Function]
def test_unmerged_files(self):
"""Returns sorted list of conflicted file paths."""
svc = TestableGitMerge()
repo = MagicMock()
repo.index.unmerged_blobs.return_value = {"b.txt": [], "a.txt": []}
result = svc._get_unmerged_file_paths(repo)
assert result == ["a.txt", "b.txt"]
# #endregion test_unmerged_files
# #region test_unmerged_exception [C:2] [TYPE Function]
def test_unmerged_exception(self):
"""Exception returns empty list."""
svc = TestableGitMerge()
repo = MagicMock()
repo.index.unmerged_blobs.side_effect = Exception("index error")
assert svc._get_unmerged_file_paths(repo) == []
# #endregion test_unmerged_exception
# ── get_merge_status ──
class TestGetMergeStatus:
"""get_merge_status — unfinished merge detection."""
# #region test_no_merge_in_progress [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_no_merge_in_progress(self):
"""No MERGE_HEAD → has_unfinished_merge=false."""
repo = MagicMock()
repo.git_dir = "/tmp/fake_repo/.git"
repo.active_branch.name = "dev"
repo.working_tree_dir = "/tmp/fake_repo"
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
with patch("os.path.exists", return_value=False):
result = await svc.get_merge_status(1)
assert result["has_unfinished_merge"] is False
assert result["current_branch"] == "dev"
assert result["conflicts_count"] == 0
# #endregion test_no_merge_in_progress
# #region test_merge_in_progress [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_merge_in_progress(self):
"""MERGE_HEAD exists → has_unfinished_merge=true."""
repo = MagicMock()
repo.git_dir = "/tmp/fake_repo/.git"
repo.active_branch.name = "dev"
repo.working_tree_dir = "/tmp/fake_repo"
repo.index.unmerged_blobs.return_value = {"conflict.txt": [(2, MagicMock()), (3, MagicMock())]}
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
with patch("os.path.exists", return_value=True), \
patch("pathlib.Path.read_text", return_value="abc123def456"):
result = await svc.get_merge_status(1)
assert result["has_unfinished_merge"] is True
assert result["merge_head"] == "abc123def456"
assert result["conflicts_count"] >= 0
# #endregion test_merge_in_progress
# ── get_merge_conflicts ──
class TestGetMergeConflicts:
"""get_merge_conflicts — list conflict details."""
# #region test_conflicts_listed [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_conflicts_listed(self):
"""Unmerged blobs parsed into mine/theirs content."""
repo = MagicMock()
mine_blob = MagicMock()
mine_blob.data_stream.read.return_value = b"mine content"
theirs_blob = MagicMock()
theirs_blob.data_stream.read.return_value = b"theirs content"
repo.index.unmerged_blobs.return_value = {
"file.txt": [(2, mine_blob), (3, theirs_blob)],
}
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
result = await svc.get_merge_conflicts(1)
assert len(result) == 1
assert result[0]["file_path"] == "file.txt"
assert result[0]["mine"] == "mine content"
assert result[0]["theirs"] == "theirs content"
# #endregion test_conflicts_listed
# #region test_no_conflicts [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_no_conflicts(self):
"""No unmerged blobs → empty list."""
repo = MagicMock()
repo.index.unmerged_blobs.return_value = {}
svc = TestableGitMerge(repo)
svc.get_repo = AsyncMock(return_value=repo)
result = await svc.get_merge_conflicts(1)
assert result == []
# #endregion test_no_conflicts
# ── resolve_merge_conflicts ──
class TestResolveMergeConflicts:
"""resolve_merge_conflicts — strategy-based resolution."""
# #region test_resolve_mine [C:2] [TYPE Function]
def test_resolve_mine(self):
"""Strategy 'mine' → checkout --ours."""
repo = MagicMock()
repo.working_tree_dir = "/tmp/repo"
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
result = svc.resolve_merge_conflicts(1, [{"file_path": "a.txt", "resolution": "mine"}])
repo.git.checkout.assert_called_with("--ours", "--", "a.txt")
repo.git.add.assert_called_with("a.txt")
assert result == ["a.txt"]
# #endregion test_resolve_mine
# #region test_resolve_theirs [C:2] [TYPE Function]
def test_resolve_theirs(self):
"""Strategy 'theirs' → checkout --theirs."""
repo = MagicMock()
repo.working_tree_dir = "/tmp/repo"
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
result = svc.resolve_merge_conflicts(1, [{"file_path": "b.txt", "resolution": "theirs"}])
repo.git.checkout.assert_called_with("--theirs", "--", "b.txt")
assert result == ["b.txt"]
# #endregion test_resolve_theirs
# #region test_resolve_manual [C:2] [TYPE Function]
def test_resolve_manual(self):
"""Strategy 'manual' → writes content to file."""
repo = MagicMock()
repo.working_tree_dir = "/tmp/repo"
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with patch("builtins.open", mock_open()) as mf, \
patch("os.makedirs"), \
patch("os.path.abspath", side_effect=lambda p: p if p.startswith("/") else f"/tmp/repo/{p}"):
result = svc.resolve_merge_conflicts(1, [
{"file_path": "c.txt", "resolution": "manual", "content": "resolved text"},
])
assert result == ["c.txt"]
repo.git.add.assert_called_with("c.txt")
# #endregion test_resolve_manual
# #region test_resolve_invalid_strategy [C:2] [TYPE Function]
def test_resolve_invalid_strategy(self):
"""Unknown strategy → HTTPException 400."""
repo = MagicMock()
repo.working_tree_dir = "/tmp/repo"
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with pytest.raises(HTTPException, match="Unsupported"):
svc.resolve_merge_conflicts(1, [{"file_path": "x.txt", "resolution": "invalid"}])
# #endregion test_resolve_invalid_strategy
# #region test_resolve_empty_filepath [C:2] [TYPE Function]
def test_resolve_empty_filepath(self):
"""Missing file_path → HTTPException 400."""
repo = MagicMock()
repo.working_tree_dir = "/tmp/repo"
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with pytest.raises(HTTPException, match="file_path is required"):
svc.resolve_merge_conflicts(1, [{"file_path": "", "resolution": "mine"}])
# #endregion test_resolve_empty_filepath
# #region test_resolve_empty_resolutions [C:2] [TYPE Function]
def test_resolve_empty_resolutions(self):
"""Empty resolutions list → returns []."""
repo = MagicMock()
repo.working_tree_dir = "/tmp/repo"
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
assert svc.resolve_merge_conflicts(1, []) == []
assert svc.resolve_merge_conflicts(1, None) == []
# #endregion test_resolve_empty_resolutions
# ── abort_merge ──
class TestAbortMerge:
"""abort_merge — cancel ongoing merge."""
# #region test_abort_success [C:2] [TYPE Function]
def test_abort_success(self):
"""Successful abort → status='aborted'."""
repo = MagicMock()
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
result = svc.abort_merge(1)
repo.git.merge.assert_called_with("--abort")
assert result["status"] == "aborted"
# #endregion test_abort_success
# #region test_abort_no_merge [C:2] [TYPE Function]
def test_abort_no_merge(self):
"""No merge in progress → status='no_merge_in_progress'."""
from git.exc import GitCommandError
repo = MagicMock()
repo.git.merge.side_effect = GitCommandError("merge", "fatal: There is no merge to abort")
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
result = svc.abort_merge(1)
assert result["status"] == "no_merge_in_progress"
# #endregion test_abort_no_merge
# #region test_abort_conflict [C:2] [TYPE Function]
def test_abort_conflict(self):
"""Other GitCommandError → HTTPException 409."""
from git.exc import GitCommandError
repo = MagicMock()
repo.git.merge.side_effect = GitCommandError("merge", "fatal: something else")
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with pytest.raises(HTTPException, match="Cannot abort"):
svc.abort_merge(1)
# #endregion test_abort_conflict
# ── continue_merge ──
class TestContinueMerge:
"""continue_merge — finalize merge after resolution."""
# #region test_continue_with_message [C:2] [TYPE Function]
def test_continue_with_message(self):
"""Commit with custom message."""
repo = MagicMock()
repo.index.unmerged_blobs.return_value = {}
repo.head.commit.hexsha = "abc123"
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
result = svc.continue_merge(1, message="resolve conflicts")
repo.git.commit.assert_called_with("-m", "resolve conflicts")
assert result["status"] == "committed"
assert result["commit_hash"] == "abc123"
# #endregion test_continue_with_message
# #region test_continue_no_message [C:2] [TYPE Function]
def test_continue_no_message(self):
"""No message → --no-edit."""
repo = MagicMock()
repo.index.unmerged_blobs.return_value = {}
repo.head.commit.hexsha = "def456"
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
result = svc.continue_merge(1)
repo.git.commit.assert_called_with("--no-edit")
assert result["status"] == "committed"
# #endregion test_continue_no_message
# #region test_continue_unresolved_conflicts [C:2] [TYPE Function]
def test_continue_unresolved_conflicts(self):
"""Unmerged files remain → HTTPException 409."""
repo = MagicMock()
repo.index.unmerged_blobs.return_value = {"file.txt": [(2, MagicMock())]}
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with pytest.raises(HTTPException) as exc_info:
svc.continue_merge(1)
assert exc_info.value.status_code == 409
# #endregion test_continue_unresolved_conflicts
# #region test_continue_nothing_to_commit [C:2] [TYPE Function]
def test_continue_nothing_to_commit(self):
"""Nothing to commit → status='already_clean'."""
from git.exc import GitCommandError
repo = MagicMock()
repo.index.unmerged_blobs.return_value = {}
repo.git.commit.side_effect = GitCommandError("commit", "nothing to commit")
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
result = svc.continue_merge(1)
assert result["status"] == "already_clean"
# #endregion test_continue_nothing_to_commit
# ── promote_direct_merge ──
class TestPromoteDirectMerge:
"""promote_direct_merge — direct branch-to-branch merge."""
# #region test_promote_same_branch [C:2] [TYPE Function]
def test_promote_same_branch(self):
"""Same from/to → HTTPException 400."""
repo = MagicMock()
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with pytest.raises(HTTPException, match="must be different"):
svc.promote_direct_merge(1, "dev", "dev")
# #endregion test_promote_same_branch
# #region test_promote_empty_branch [C:2] [TYPE Function]
def test_promote_empty_branch(self):
"""Empty branch name → HTTPException 400."""
repo = MagicMock()
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with pytest.raises(HTTPException, match="required"):
svc.promote_direct_merge(1, "", "main")
# #endregion test_promote_empty_branch
# #region test_promote_success [C:2] [TYPE Function]
def test_promote_success(self):
"""Successful merge → status='merged'."""
repo = MagicMock()
repo.active_branch.name = "dev"
repo.heads = [MagicMock(name="dev"), MagicMock(name="main")]
repo.heads[0].name = "dev"
repo.heads[1].name = "main"
repo.refs = [MagicMock()]
repo.refs[0].name = "origin/main"
origin = MagicMock()
repo.remote.return_value = origin
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
result = svc.promote_direct_merge(1, "dev", "main")
assert result["status"] == "merged"
assert result["from_branch"] == "dev"
assert result["to_branch"] == "main"
origin.fetch.assert_called_once()
repo.git.merge.assert_called_once()
origin.push.assert_called_once()
repo.git.checkout.assert_called()
# #endregion test_promote_success
# #region test_promote_no_origin [C:2] [TYPE Function]
def test_promote_no_origin(self):
"""No origin remote → HTTPException 400."""
repo = MagicMock()
repo.active_branch.name = "dev"
repo.remote.side_effect = ValueError("no remote")
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with pytest.raises(HTTPException, match="origin"):
svc.promote_direct_merge(1, "dev", "main")
# #endregion test_promote_no_origin
# #region test_promote_source_not_found [C:2] [TYPE Function]
def test_promote_source_not_found(self):
"""Source branch not in heads or refs → HTTPException 404."""
repo = MagicMock()
repo.active_branch.name = "dev"
repo.heads = []
repo.refs = []
origin = MagicMock()
repo.remote.return_value = origin
svc = TestableGitMerge(repo)
svc.get_repo = MagicMock(return_value=repo)
with pytest.raises(HTTPException, match="not found"):
svc.promote_direct_merge(1, "nonexistent", "main")
# #endregion test_promote_source_not_found
# #endregion Test.Git.Merge

View File

@@ -0,0 +1,288 @@
# #region Test.Git.Sync [C:3] [TYPE Module] [SEMANTICS test,git,sync,push,pull,remote]
# @BRIEF Tests for GitServiceSyncMixin — push_changes and pull_changes with origin host alignment, error mapping, and merge detection.
# @RELATION BINDS_TO -> [GitServiceSyncMixin]
# @TEST_EDGE: push_no_heads -> warning logged, no push attempted
# @TEST_EDGE: push_no_origin -> HTTPException 400
# @TEST_EDGE: push_non_fast_forward -> HTTPException 409
# @TEST_EDGE: push_success -> origin.push called
# @TEST_EDGE: push_no_tracking -> set-upstream push
# @TEST_EDGE: pull_unfinished_merge -> HTTPException 409
# @TEST_EDGE: pull_no_origin -> HTTPException 400
# @TEST_EDGE: pull_no_remote_branch -> HTTPException 409
# @TEST_EDGE: pull_conflict -> HTTPException 409
# @TEST_EDGE: pull_success -> fetch + pull called
import contextlib
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
from fastapi import HTTPException
from git.exc import GitCommandError
from src.services.git._sync import GitServiceSyncMixin
from src.services.git._merge import GitServiceMergeMixin
class TestableGitSync(GitServiceSyncMixin, GitServiceMergeMixin):
"""Concrete test class providing _locked, get_repo, and URL mixin stubs."""
def __init__(self, mock_repo=None):
self._mock_repo = mock_repo or MagicMock()
async def get_repo(self, dashboard_id):
return self._mock_repo
def _locked(self, dashboard_id):
@contextlib.contextmanager
def _lock():
yield
return _lock()
def _align_origin_host_with_config(self, **kwargs):
return None
# ── push_changes ──
class TestPushChanges:
"""push_changes — push local commits to origin."""
# #region test_push_no_heads [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_push_no_heads(self):
"""No local branches → returns early, no push."""
repo = MagicMock()
repo.heads = []
svc = TestableGitSync(repo)
with patch("src.services.git._sync.SessionLocal"):
result = await svc.push_changes(1)
assert result is None
# #endregion test_push_no_heads
# #region test_push_no_origin [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_push_no_origin(self):
"""No origin remote → HTTPException 400."""
repo = MagicMock()
repo.heads = [MagicMock()]
repo.remote.side_effect = ValueError("no remote")
svc = TestableGitSync(repo)
with patch("src.services.git._sync.SessionLocal"):
with pytest.raises(HTTPException, match="origin"):
await svc.push_changes(1)
# #endregion test_push_no_origin
# #region test_push_success_with_tracking [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_push_success_with_tracking(self):
"""Branch with tracking → origin.push called."""
repo = MagicMock()
repo.heads = [MagicMock()]
branch = MagicMock()
branch.name = "dev"
branch.tracking_branch.return_value = MagicMock()
repo.active_branch = branch
origin = MagicMock()
origin.urls = ["https://gitea.com/org/repo.git"]
origin.push.return_value = []
repo.remote.return_value = origin
svc = TestableGitSync(repo)
with patch("src.services.git._sync.SessionLocal") as mock_session_cls:
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_session.query.return_value.filter.return_value.first.return_value = None
await svc.push_changes(1)
origin.push.assert_called_once()
# #endregion test_push_success_with_tracking
# #region test_push_no_tracking_set_upstream [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_push_no_tracking_set_upstream(self):
"""No tracking branch → --set-upstream push."""
repo = MagicMock()
repo.heads = [MagicMock()]
branch = MagicMock()
branch.name = "feature"
branch.tracking_branch.return_value = None
repo.active_branch = branch
origin = MagicMock()
origin.urls = ["https://gitea.com/org/repo.git"]
repo.remote.return_value = origin
svc = TestableGitSync(repo)
with patch("src.services.git._sync.SessionLocal") as mock_session_cls:
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_session.query.return_value.filter.return_value.first.return_value = None
await svc.push_changes(1)
repo.git.push.assert_called_with("--set-upstream", "origin", "feature:feature")
# #endregion test_push_no_tracking_set_upstream
# #region test_push_non_fast_forward [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_push_non_fast_forward(self):
"""Non-fast-forward rejection → HTTPException 409."""
repo = MagicMock()
repo.heads = [MagicMock()]
branch = MagicMock()
branch.name = "dev"
branch.tracking_branch.return_value = MagicMock()
repo.active_branch = branch
origin = MagicMock()
origin.urls = ["https://gitea.com/org/repo.git"]
origin.push.side_effect = GitCommandError("push", "rejected non-fast-forward")
repo.remote.return_value = origin
svc = TestableGitSync(repo)
with patch("src.services.git._sync.SessionLocal") as mock_session_cls:
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_session.query.return_value.filter.return_value.first.return_value = None
with pytest.raises(HTTPException) as exc_info:
await svc.push_changes(1)
assert exc_info.value.status_code == 409
# #endregion test_push_non_fast_forward
# #region test_push_error_flags [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_push_error_flags(self):
"""Push info with ERROR flag → raises Exception."""
repo = MagicMock()
repo.heads = [MagicMock()]
branch = MagicMock()
branch.name = "dev"
branch.tracking_branch.return_value = MagicMock()
repo.active_branch = branch
origin = MagicMock()
origin.urls = ["https://gitea.com/org/repo.git"]
push_info = MagicMock()
push_info.flags = push_info.ERROR
push_info.remote_ref_string = "refs/heads/dev"
push_info.summary = "remote error"
origin.push.return_value = [push_info]
repo.remote.return_value = origin
svc = TestableGitSync(repo)
with patch("src.services.git._sync.SessionLocal") as mock_session_cls:
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_session.query.return_value.filter.return_value.first.return_value = None
with pytest.raises(HTTPException, match="push failed"):
await svc.push_changes(1)
# #endregion test_push_error_flags
# ── pull_changes ──
class TestPullChanges:
"""pull_changes — pull from origin with merge detection."""
# #region test_pull_unfinished_merge [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pull_unfinished_merge(self):
"""MERGE_HEAD exists → HTTPException 409."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.active_branch.name = "dev"
repo.working_tree_dir = "/tmp/repo"
repo.index.unmerged_blobs.return_value = {}
svc = TestableGitSync(repo)
with patch("os.path.exists", return_value=True), \
patch("pathlib.Path.read_text", return_value="merge_head_sha"):
with pytest.raises(HTTPException) as exc_info:
await svc.pull_changes(1)
assert exc_info.value.status_code == 409
# #endregion test_pull_unfinished_merge
# #region test_pull_no_origin [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pull_no_origin(self):
"""No origin remote → HTTPException 400."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.remote.side_effect = ValueError("no remote")
svc = TestableGitSync(repo)
with patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException, match="origin"):
await svc.pull_changes(1)
# #endregion test_pull_no_origin
# #region test_pull_no_remote_branch [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pull_no_remote_branch(self):
"""Remote branch doesn't exist → HTTPException 409."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.active_branch.name = "feature"
origin = MagicMock()
origin.urls = ["https://gitea.com/org/repo.git"]
repo.remote.return_value = origin
repo.refs = [] # no origin/feature ref
svc = TestableGitSync(repo)
with patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException, match="does not exist"):
await svc.pull_changes(1)
# #endregion test_pull_no_remote_branch
# #region test_pull_success [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pull_success(self):
"""Successful pull → fetch + pull called."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.active_branch.name = "dev"
origin = MagicMock()
origin.urls = ["https://gitea.com/org/repo.git"]
repo.remote.return_value = origin
remote_ref = MagicMock()
remote_ref.name = "origin/dev"
repo.refs = [remote_ref]
svc = TestableGitSync(repo)
with patch("os.path.exists", return_value=False):
await svc.pull_changes(1)
origin.fetch.assert_called_once_with(prune=True)
repo.git.pull.assert_called_with("--no-rebase", "origin", "dev")
# #endregion test_pull_success
# #region test_pull_conflict [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pull_conflict(self):
"""Merge conflict during pull → HTTPException 409."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.active_branch.name = "dev"
origin = MagicMock()
origin.urls = ["https://gitea.com/org/repo.git"]
repo.remote.return_value = origin
remote_ref = MagicMock()
remote_ref.name = "origin/dev"
repo.refs = [remote_ref]
repo.git.pull.side_effect = GitCommandError("pull", "CONFLICT content")
svc = TestableGitSync(repo)
with patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException) as exc_info:
await svc.pull_changes(1)
assert exc_info.value.status_code == 409
# #endregion test_pull_conflict
# #region test_pull_generic_error [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_pull_generic_error(self):
"""Generic exception → HTTPException 500."""
repo = MagicMock()
repo.git_dir = "/tmp/repo/.git"
repo.active_branch.name = "dev"
origin = MagicMock()
origin.urls = ["https://gitea.com/org/repo.git"]
repo.remote.return_value = origin
remote_ref = MagicMock()
remote_ref.name = "origin/dev"
repo.refs = [remote_ref]
repo.git.pull.side_effect = Exception("network failure")
svc = TestableGitSync(repo)
with patch("os.path.exists", return_value=False):
with pytest.raises(HTTPException) as exc_info:
await svc.pull_changes(1)
assert exc_info.value.status_code == 500
# #endregion test_pull_generic_error
# #endregion Test.Git.Sync

View File

@@ -0,0 +1,347 @@
# #region Test.Git.Url [C:3] [TYPE Module] [SEMANTICS test,git,url,parse,remote]
# @BRIEF Tests for GitServiceUrlMixin — host extraction, credential stripping, host replacement, origin alignment, remote identity parsing, server URL derivation, URL normalization.
# @RELATION BINDS_TO -> [GitServiceUrlMixin]
# @TEST_EDGE: empty_url -> returns None or empty
# @TEST_EDGE: non_http_scheme -> returns None (ssh/git protocol)
# @TEST_EDGE: url_with_port -> host:port preserved
# @TEST_EDGE: url_with_credentials -> credentials stripped/replaced correctly
# @TEST_EDGE: ssh_url -> parsed via git@ split
# @TEST_EDGE: missing_owner_repo -> raises HTTPException 400
# @TEST_EDGE: empty_server_url -> raises HTTPException 400
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import MagicMock, patch
from fastapi import HTTPException
from src.services.git._url import GitServiceUrlMixin
class TestableGitUrl(GitServiceUrlMixin):
"""Minimal concrete subclass for testing URL mixin methods."""
pass
# ── _extract_http_host ──
class TestExtractHttpHost:
"""_extract_http_host — host[:port] extraction from HTTP(S) URLs."""
# #region test_extract_host_https [C:2] [TYPE Function]
def test_extract_host_https(self):
"""Standard HTTPS URL returns lowercase host."""
svc = TestableGitUrl()
result = svc._extract_http_host("https://Git.Example.COM/org/repo.git")
assert result == "git.example.com"
# #endregion test_extract_host_https
# #region test_extract_host_with_port [C:2] [TYPE Function]
def test_extract_host_with_port(self):
"""URL with port returns host:port."""
svc = TestableGitUrl()
result = svc._extract_http_host("http://gitea.local:3000/repo")
assert result == "gitea.local:3000"
# #endregion test_extract_host_with_port
# #region test_extract_host_empty [C:2] [TYPE Function]
def test_extract_host_empty(self):
"""Empty/None input returns None."""
svc = TestableGitUrl()
assert svc._extract_http_host(None) is None
assert svc._extract_http_host("") is None
assert svc._extract_http_host(" ") is None
# #endregion test_extract_host_empty
# #region test_extract_host_non_http [C:2] [TYPE Function]
def test_extract_host_non_http(self):
"""SSH or FTP scheme returns None."""
svc = TestableGitUrl()
assert svc._extract_http_host("git@github.com:org/repo.git") is None
assert svc._extract_http_host("ftp://files.example.com/repo") is None
# #endregion test_extract_host_non_http
# #region test_extract_host_no_hostname [C:2] [TYPE Function]
def test_extract_host_no_hostname(self):
"""Malformed URL with no hostname returns None."""
svc = TestableGitUrl()
assert svc._extract_http_host("http://") is None
# #endregion test_extract_host_no_hostname
# ── _strip_url_credentials ──
class TestStripUrlCredentials:
"""_strip_url_credentials — remove user:pass from URL."""
# #region test_strip_credentials [C:2] [TYPE Function]
def test_strip_credentials(self):
"""Credentials removed, scheme/host/path preserved."""
svc = TestableGitUrl()
result = svc._strip_url_credentials("https://oauth2:token123@git.example.com/org/repo.git")
assert result == "https://git.example.com/org/repo.git"
# #endregion test_strip_credentials
# #region test_strip_no_credentials [C:2] [TYPE Function]
def test_strip_no_credentials(self):
"""URL without credentials returned unchanged."""
svc = TestableGitUrl()
result = svc._strip_url_credentials("https://git.example.com/org/repo.git")
assert result == "https://git.example.com/org/repo.git"
# #endregion test_strip_no_credentials
# #region test_strip_empty [C:2] [TYPE Function]
def test_strip_empty(self):
"""Empty string returned as-is."""
svc = TestableGitUrl()
assert svc._strip_url_credentials("") == ""
# #endregion test_strip_empty
# #region test_strip_non_http_passthrough [C:2] [TYPE Function]
def test_strip_non_http_passthrough(self):
"""Non-HTTP URL returned unchanged."""
svc = TestableGitUrl()
url = "git@github.com:org/repo.git"
assert svc._strip_url_credentials(url) == url
# #endregion test_strip_non_http_passthrough
# #region test_strip_with_port [C:2] [TYPE Function]
def test_strip_with_port(self):
"""Port preserved after credential stripping."""
svc = TestableGitUrl()
result = svc._strip_url_credentials("https://user:pass@gitea.local:3000/repo")
assert result == "https://gitea.local:3000/repo"
# #endregion test_strip_with_port
# ── _replace_host_in_url ──
class TestReplaceHostInUrl:
"""_replace_host_in_url — swap origin host with config host."""
# #region test_replace_host_basic [C:2] [TYPE Function]
def test_replace_host_basic(self):
"""Source host replaced with config host, credentials preserved."""
svc = TestableGitUrl()
result = svc._replace_host_in_url(
"https://old-host.com/org/repo.git",
"https://new-host.com",
)
assert result == "https://new-host.com/org/repo.git"
# #endregion test_replace_host_basic
# #region test_replace_host_preserves_auth [C:2] [TYPE Function]
def test_replace_host_preserves_auth(self):
"""Credentials from source URL preserved on new host."""
svc = TestableGitUrl()
result = svc._replace_host_in_url(
"https://oauth2:tok123@old-host.com/org/repo.git",
"https://new-host.com:3000",
)
assert "oauth2:tok123@new-host.com:3000" in result
assert "/org/repo.git" in result
# #endregion test_replace_host_preserves_auth
# #region test_replace_host_empty_inputs [C:2] [TYPE Function]
def test_replace_host_empty_inputs(self):
"""Empty source or config returns None."""
svc = TestableGitUrl()
assert svc._replace_host_in_url(None, "https://x.com") is None
assert svc._replace_host_in_url("https://x.com", None) is None
assert svc._replace_host_in_url("", "") is None
# #endregion test_replace_host_empty_inputs
# #region test_replace_host_non_http [C:2] [TYPE Function]
def test_replace_host_non_http(self):
"""Non-HTTP source or config returns None."""
svc = TestableGitUrl()
assert svc._replace_host_in_url("git@old:x.git", "https://new.com") is None
assert svc._replace_host_in_url("https://old.com/x", "ftp://new.com") is None
# #endregion test_replace_host_non_http
# ── _parse_remote_repo_identity ──
class TestParseRemoteRepoIdentity:
"""_parse_remote_repo_identity — extract owner/repo from remote URL."""
# #region test_parse_https_url [C:2] [TYPE Function]
def test_parse_https_url(self):
"""HTTPS URL parsed into owner, repo, namespace."""
svc = TestableGitUrl()
result = svc._parse_remote_repo_identity("https://gitea.example.com/org/repo.git")
assert result["owner"] == "org"
assert result["repo"] == "repo"
assert result["namespace"] == "org"
assert result["full_name"] == "org/repo"
# #endregion test_parse_https_url
# #region test_parse_ssh_url [C:2] [TYPE Function]
def test_parse_ssh_url(self):
"""SSH git@ URL parsed via colon split."""
svc = TestableGitUrl()
result = svc._parse_remote_repo_identity("git@github.com:myorg/myrepo.git")
assert result["owner"] == "myorg"
assert result["repo"] == "myrepo"
assert result["namespace"] == "myorg"
# #endregion test_parse_ssh_url
# #region test_parse_nested_namespace [C:2] [TYPE Function]
def test_parse_nested_namespace(self):
"""Nested namespace (subgroups) preserved."""
svc = TestableGitUrl()
result = svc._parse_remote_repo_identity("https://gitlab.com/group/subgroup/project.git")
assert result["owner"] == "group"
assert result["repo"] == "project"
assert result["namespace"] == "group/subgroup"
assert result["full_name"] == "group/subgroup/project"
# #endregion test_parse_nested_namespace
# #region test_parse_empty_url_raises [C:2] [TYPE Function]
def test_parse_empty_url_raises(self):
"""Empty URL raises HTTPException 400."""
svc = TestableGitUrl()
with pytest.raises(HTTPException, match="empty"):
svc._parse_remote_repo_identity("")
# #endregion test_parse_empty_url_raises
# #region test_parse_single_segment_raises [C:2] [TYPE Function]
def test_parse_single_segment_raises(self):
"""URL with <2 path segments raises HTTPException 400."""
svc = TestableGitUrl()
with pytest.raises(HTTPException, match="Cannot parse"):
svc._parse_remote_repo_identity("https://host.com/onlyone")
# #endregion test_parse_single_segment_raises
# #region test_parse_no_git_suffix [C:2] [TYPE Function]
def test_parse_no_git_suffix(self):
"""URL without .git suffix parsed correctly."""
svc = TestableGitUrl()
result = svc._parse_remote_repo_identity("https://host.com/owner/repo")
assert result["repo"] == "repo"
# #endregion test_parse_no_git_suffix
# ── _derive_server_url_from_remote ──
class TestDeriveServerUrlFromRemote:
"""_derive_server_url_from_remote — build API base URL."""
# #region test_derive_https [C:2] [TYPE Function]
def test_derive_https(self):
"""HTTPS URL → scheme://host base."""
svc = TestableGitUrl()
result = svc._derive_server_url_from_remote("https://gitea.example.com/org/repo.git")
assert result == "https://gitea.example.com"
# #endregion test_derive_https
# #region test_derive_with_port [C:2] [TYPE Function]
def test_derive_with_port(self):
"""Port preserved in derived URL."""
svc = TestableGitUrl()
result = svc._derive_server_url_from_remote("http://gitea.local:3000/repo")
assert result == "http://gitea.local:3000"
# #endregion test_derive_with_port
# #region test_derive_ssh_returns_none [C:2] [TYPE Function]
def test_derive_ssh_returns_none(self):
"""SSH URL returns None."""
svc = TestableGitUrl()
assert svc._derive_server_url_from_remote("git@host:org/repo.git") is None
# #endregion test_derive_ssh_returns_none
# #region test_derive_empty_returns_none [C:2] [TYPE Function]
def test_derive_empty_returns_none(self):
"""Empty input returns None."""
svc = TestableGitUrl()
assert svc._derive_server_url_from_remote("") is None
assert svc._derive_server_url_from_remote(None) is None
# #endregion test_derive_empty_returns_none
# ── _normalize_git_server_url ──
class TestNormalizeGitServerUrl:
"""_normalize_git_server_url — strip trailing slash."""
# #region test_normalize_strips_slash [C:2] [TYPE Function]
def test_normalize_strips_slash(self):
"""Trailing slash removed."""
svc = TestableGitUrl()
assert svc._normalize_git_server_url("https://gitea.com/") == "https://gitea.com"
# #endregion test_normalize_strips_slash
# #region test_normalize_no_change [C:2] [TYPE Function]
def test_normalize_no_change(self):
"""URL without trailing slash unchanged."""
svc = TestableGitUrl()
assert svc._normalize_git_server_url("https://gitea.com") == "https://gitea.com"
# #endregion test_normalize_no_change
# #region test_normalize_empty_raises [C:2] [TYPE Function]
def test_normalize_empty_raises(self):
"""Empty URL raises HTTPException 400."""
svc = TestableGitUrl()
with pytest.raises(HTTPException, match="required"):
svc._normalize_git_server_url("")
# #endregion test_normalize_empty_raises
# ── _align_origin_host_with_config ──
class TestAlignOriginHostWithConfig:
"""_align_origin_host_with_config — auto-align origin host drift."""
# #region test_align_no_drift [C:2] [TYPE Function]
def test_align_no_drift(self):
"""Matching hosts → no action, returns None."""
svc = TestableGitUrl()
origin = MagicMock()
result = svc._align_origin_host_with_config(
dashboard_id=1,
origin=origin,
config_url="https://gitea.com",
current_origin_url="https://gitea.com/org/repo.git",
binding_remote_url=None,
)
assert result is None
origin.set_url.assert_not_called()
# #endregion test_align_no_drift
# #region test_align_host_mismatch [C:2] [TYPE Function]
@patch("src.services.git._url.SessionLocal")
def test_align_host_mismatch(self, mock_session_cls):
"""Mismatched hosts → origin.set_url called, DB updated."""
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_session.query.return_value.filter.return_value.first.return_value = None
svc = TestableGitUrl()
origin = MagicMock()
result = svc._align_origin_host_with_config(
dashboard_id=1,
origin=origin,
config_url="https://new-host.com",
current_origin_url="https://old-host.com/org/repo.git",
binding_remote_url=None,
)
assert result is not None
assert "new-host.com" in result
origin.set_url.assert_called_once()
# #endregion test_align_host_mismatch
# #region test_align_missing_config [C:2] [TYPE Function]
def test_align_missing_config(self):
"""No config URL → returns None, no action."""
svc = TestableGitUrl()
origin = MagicMock()
result = svc._align_origin_host_with_config(
dashboard_id=1, origin=origin, config_url=None,
current_origin_url="https://host.com/repo", binding_remote_url=None,
)
assert result is None
# #endregion test_align_missing_config
# #endregion Test.Git.Url

View File

@@ -0,0 +1,289 @@
# #region Test.AuthService [C:3] [TYPE Module] [SEMANTICS test,auth,service]
# @BRIEF Verify AuthService contracts — authenticate_user, create_session, provision_adfs_user.
# @RELATION BINDS_TO -> [AuthService]
# @TEST_EDGE: user_not_found -> authenticate_user returns None
# @TEST_EDGE: user_inactive -> authenticate_user returns None
# @TEST_EDGE: wrong_password -> authenticate_user returns None
# @TEST_EDGE: adfs_new_user -> provision_adfs_user creates User and commits
# @TEST_EDGE: adfs_existing_user -> provision_adfs_user syncs roles without creating
# @TEST_EDGE: adfs_no_upn_fallback_email -> username derived from email when upn absent
from pathlib import Path
import sys
from contextlib import contextmanager
from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
import pytest
# ── Helpers (C1 — anchor pair only) ──
# #region _make_user
def _make_user(**kwargs):
defaults = {
"id": "user-001",
"username": "alice",
"email": "alice@example.com",
"password_hash": "$2b$12$FAKEHASH",
"is_active": True,
"roles": [],
"last_login": None,
}
defaults.update(kwargs)
user = MagicMock()
for k, v in defaults.items():
setattr(user, k, v)
return user
# #endregion _make_user
# #region _make_role
def _make_role(name: str):
role = MagicMock()
role.name = name
return role
# #endregion _make_role
# #region _noop_belief_scope
@contextmanager
def _noop_belief_scope(_id):
yield
# #endregion _noop_belief_scope
# ── Fixtures ──
@pytest.fixture()
def mock_db():
db = MagicMock()
db.commit = MagicMock()
db.refresh = MagicMock()
db.add = MagicMock()
return db
# ════════════════════════════════════════════════════════════════
# authenticate_user
# ════════════════════════════════════════════════════════════════
class TestAuthenticateUser:
"""authenticate_user — credential validation and account-state gating."""
# #region test_authenticate_user_success
# @BRIEF Active user with correct password returns User and updates last_login.
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.verify_password", return_value=True)
@patch("src.services.auth_service.AuthRepository")
def test_authenticate_user_success(self, MockRepo, _vp, _bs, mock_db):
from src.services.auth_service import AuthService
expected_user = _make_user(username="alice", is_active=True)
instance = MockRepo.return_value
instance.get_user_by_username.return_value = expected_user
svc = AuthService(mock_db)
result = svc.authenticate_user("alice", "correct-password")
assert result is expected_user
assert result.last_login is not None
mock_db.commit.assert_called_once()
mock_db.refresh.assert_called_once_with(expected_user)
# #endregion test_authenticate_user_success
# #region test_authenticate_user_not_found
# @BRIEF Returns None when username does not exist in the database.
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.verify_password")
@patch("src.services.auth_service.AuthRepository")
def test_authenticate_user_not_found(self, MockRepo, mock_vp, _bs, mock_db):
from src.services.auth_service import AuthService
MockRepo.return_value.get_user_by_username.return_value = None
svc = AuthService(mock_db)
result = svc.authenticate_user("ghost", "pw")
assert result is None
mock_vp.assert_not_called()
# #endregion test_authenticate_user_not_found
# #region test_authenticate_user_inactive
# @BRIEF Returns None when user exists but is_active is False.
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.verify_password")
@patch("src.services.auth_service.AuthRepository")
def test_authenticate_user_inactive(self, MockRepo, mock_vp, _bs, mock_db):
from src.services.auth_service import AuthService
inactive_user = _make_user(is_active=False)
MockRepo.return_value.get_user_by_username.return_value = inactive_user
svc = AuthService(mock_db)
result = svc.authenticate_user("alice", "pw")
assert result is None
mock_vp.assert_not_called()
# #endregion test_authenticate_user_inactive
# #region test_authenticate_user_wrong_password
# @BRIEF Returns None when password hash verification fails.
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.verify_password", return_value=False)
@patch("src.services.auth_service.AuthRepository")
def test_authenticate_user_wrong_password(self, MockRepo, _vp, _bs, mock_db):
from src.services.auth_service import AuthService
active_user = _make_user(is_active=True)
MockRepo.return_value.get_user_by_username.return_value = active_user
svc = AuthService(mock_db)
result = svc.authenticate_user("alice", "wrong-password")
assert result is None
mock_db.commit.assert_not_called()
# #endregion test_authenticate_user_wrong_password
# ════════════════════════════════════════════════════════════════
# create_session
# ════════════════════════════════════════════════════════════════
class TestCreateSession:
"""create_session — JWT issuance with role-based scopes."""
# #region test_create_session_returns_bearer_token
# @BRIEF Returns dict with access_token and token_type='bearer'.
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.create_access_token", return_value="jwt-token-abc")
@patch("src.services.auth_service.AuthRepository")
def test_create_session_returns_bearer_token(self, MockRepo, mock_jwt, _bs, mock_db):
from src.services.auth_service import AuthService
role_admin = _make_role("admin")
role_editor = _make_role("editor")
user = _make_user(username="alice", roles=[role_admin, role_editor])
svc = AuthService(mock_db)
session = svc.create_session(user)
assert session == {"access_token": "jwt-token-abc", "token_type": "bearer"}
mock_jwt.assert_called_once_with(
data={"sub": "alice", "scopes": ["admin", "editor"]}
)
# #endregion test_create_session_returns_bearer_token
# #region test_create_session_no_roles
# @BRIEF User with no roles produces empty scopes list.
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.create_access_token", return_value="jwt-empty")
@patch("src.services.auth_service.AuthRepository")
def test_create_session_no_roles(self, MockRepo, mock_jwt, _bs, mock_db):
from src.services.auth_service import AuthService
user = _make_user(username="bob", roles=[])
svc = AuthService(mock_db)
session = svc.create_session(user)
assert session["token_type"] == "bearer"
mock_jwt.assert_called_once_with(data={"sub": "bob", "scopes": []})
# #endregion test_create_session_no_roles
# ════════════════════════════════════════════════════════════════
# provision_adfs_user
# ════════════════════════════════════════════════════════════════
class TestProvisionAdfsUser:
"""provision_adfs_user — JIT provisioning and role synchronization."""
# #region test_provision_new_adfs_user
# @BRIEF Creates new User when username not found, sets ADFS fields, commits.
@patch("src.services.auth_service.log_security_event")
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.AuthRepository")
def test_provision_new_adfs_user(self, MockRepo, _bs, mock_log, mock_db):
from src.services.auth_service import AuthService
MockRepo.return_value.get_user_by_username.return_value = None
mapped_role = _make_role("ad-users")
MockRepo.return_value.get_roles_by_ad_groups.return_value = [mapped_role]
svc = AuthService(mock_db)
user_info = {"upn": "carol@corp.com", "email": "carol@corp.com", "name": "Carol", "groups": ["AD-Users"]}
result = svc.provision_adfs_user(user_info)
mock_db.add.assert_called_once()
new_user = mock_db.add.call_args[0][0]
assert new_user.username == "carol@corp.com"
assert new_user.auth_source == "ADFS"
assert new_user.is_ad_user is True
assert new_user.is_active is True
assert new_user.roles == [mapped_role]
mock_db.commit.assert_called_once()
mock_db.refresh.assert_called_once()
mock_log.assert_called_once_with("USER_PROVISIONED", "carol@corp.com", {"source": "ADFS"})
# #endregion test_provision_new_adfs_user
# #region test_provision_existing_adfs_user
# @BRIEF Existing user gets roles synced without creating a new record.
@patch("src.services.auth_service.log_security_event")
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.AuthRepository")
def test_provision_existing_adfs_user(self, MockRepo, _bs, mock_log, mock_db):
from src.services.auth_service import AuthService
existing = _make_user(username="dave@corp.com")
MockRepo.return_value.get_user_by_username.return_value = existing
role1 = _make_role("analysts")
MockRepo.return_value.get_roles_by_ad_groups.return_value = [role1]
svc = AuthService(mock_db)
result = svc.provision_adfs_user({"upn": "dave@corp.com", "groups": ["AD-Analysts"]})
mock_db.add.assert_not_called()
mock_log.assert_not_called()
assert existing.roles == [role1]
assert existing.last_login is not None
mock_db.commit.assert_called_once()
# #endregion test_provision_existing_adfs_user
# #region test_provision_adfs_user_email_fallback
# @BRIEF Uses 'email' claim as username when 'upn' is absent.
@patch("src.services.auth_service.log_security_event")
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.AuthRepository")
def test_provision_adfs_user_email_fallback(self, MockRepo, _bs, mock_log, mock_db):
from src.services.auth_service import AuthService
MockRepo.return_value.get_user_by_username.return_value = None
MockRepo.return_value.get_roles_by_ad_groups.return_value = []
svc = AuthService(mock_db)
result = svc.provision_adfs_user({"email": "eve@corp.com", "groups": []})
new_user = mock_db.add.call_args[0][0]
assert new_user.username == "eve@corp.com"
# #endregion test_provision_adfs_user_email_fallback
# #region test_provision_adfs_user_no_groups
# @BRIEF Empty groups list results in empty roles and no error.
@patch("src.services.auth_service.log_security_event")
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
@patch("src.services.auth_service.AuthRepository")
def test_provision_adfs_user_no_groups(self, MockRepo, _bs, mock_log, mock_db):
from src.services.auth_service import AuthService
existing = _make_user(username="frank@corp.com")
MockRepo.return_value.get_user_by_username.return_value = existing
MockRepo.return_value.get_roles_by_ad_groups.return_value = []
svc = AuthService(mock_db)
result = svc.provision_adfs_user({"upn": "frank@corp.com"})
assert existing.roles == []
mock_db.commit.assert_called_once()
# #endregion test_provision_adfs_user_no_groups
# #endregion Test.AuthService

View File

@@ -1,6 +1,9 @@
# #region Test.ProfileUtils [C:3] [TYPE Module] [SEMANTICS test,profile,utils,pure-functions]
# @BRIEF Tests for profile_utils.py — sanitize, normalize, mask, validate payload, build default preference, owner tokens.
# @BRIEF Tests for profile_utils.py — sanitize, normalize, mask, validate, default preference, owner tokens, exceptions.
# @RELATION BINDS_TO -> [ProfileUtils]
# @TEST_EDGE: missing_field -> None/empty inputs handled gracefully
# @TEST_EDGE: invalid_type -> Invalid values produce validation errors
# @TEST_EDGE: external_fail -> N/A (pure functions, no external deps)
from pathlib import Path
import sys
@@ -9,365 +12,330 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
import pytest
from src.services.profile_utils import (
EnvironmentNotFoundError,
ProfileAuthorizationError,
ProfileValidationError,
build_default_preference,
mask_secret_value,
normalize_density,
normalize_owner_tokens,
normalize_start_page,
normalize_username,
sanitize_secret,
sanitize_text,
sanitize_username,
validate_update_payload,
)
def _validate(**overrides):
defaults = dict(
superset_username="admin",
show_only_my_dashboards=False,
git_email=None,
start_page="dashboards",
dashboards_table_density="comfortable",
)
defaults.update(overrides)
return validate_update_payload(**defaults)
# --- sanitize_text ---
# #region test_sanitize_text [C:2] [TYPE Function]
# @BRIEF Verify sanitize_text with None, empty, whitespace, and valid inputs.
@pytest.mark.parametrize("value, expected", [
(None, None), ("", None), (" ", None),
(" hello ", "hello"), ("world", "world"),
])
def test_sanitize_text(value, expected):
assert sanitize_text(value) == expected
# #endregion test_sanitize_text
# --- sanitize_secret ---
# #region test_sanitize_secret [C:2] [TYPE Function]
# @BRIEF Verify sanitize_secret with None, empty, whitespace, and valid secrets.
@pytest.mark.parametrize("value, expected", [
(None, None), ("", None), (" ", None),
(" my-token-abc ", "my-token-abc"),
])
def test_sanitize_secret(value, expected):
assert sanitize_secret(value) == expected
# #endregion test_sanitize_secret
# --- sanitize_username ---
# #region test_sanitize_username [C:2] [TYPE Function]
# @BRIEF Verify sanitize_username delegates to sanitize_text.
@pytest.mark.parametrize("value, expected", [
(None, None), (" admin ", "admin"),
])
def test_sanitize_username(value, expected):
assert sanitize_username(value) == expected
# #endregion test_sanitize_username
# --- normalize_username ---
# #region test_normalize_username [C:2] [TYPE Function]
# @BRIEF Verify trim+lower normalization for actor matching.
@pytest.mark.parametrize("value, expected", [
(None, None), ("", None),
(" AdminUser ", "adminuser"), ("guest", "guest"),
])
def test_normalize_username(value, expected):
assert normalize_username(value) == expected
# #endregion test_normalize_username
# --- normalize_start_page ---
# #region test_normalize_start_page [C:2] [TYPE Function]
# @BRIEF Verify start page alias mapping to canonical values.
@pytest.mark.parametrize("value, expected", [
("reports-logs", "reports"), ("dashboards", "dashboards"),
("datasets", "datasets"), ("reports", "reports"),
("settings", "dashboards"), (None, "dashboards"),
("DATASETS", "datasets"),
])
def test_normalize_start_page(value, expected):
assert normalize_start_page(value) == expected
# #endregion test_normalize_start_page
# --- normalize_density ---
# #region test_normalize_density [C:2] [TYPE Function]
# @BRIEF Verify density alias mapping to canonical values.
@pytest.mark.parametrize("value, expected", [
("free", "comfortable"), ("compact", "compact"),
("comfortable", "comfortable"), ("spacious", "comfortable"),
(None, "comfortable"),
])
def test_normalize_density(value, expected):
assert normalize_density(value) == expected
# #endregion test_normalize_density
# --- mask_secret_value ---
# #region test_mask_secret_value [C:2] [TYPE Function]
# @BRIEF Verify safe display masking for secrets of various lengths.
@pytest.mark.parametrize("value, expected", [
(None, None), ("", None),
("ab", "***"), ("abcd", "***"),
("my-token-value", "my***ue"), (" ab ", "***"),
])
def test_mask_secret_value(value, expected):
assert mask_secret_value(value) == expected
# #endregion test_mask_secret_value
# --- validate_update_payload ---
# #region test_validate_valid_payload [C:2] [TYPE Function]
# @BRIEF All-valid input returns empty error list.
def test_validate_valid_payload():
assert _validate() == []
# #endregion test_validate_valid_payload
# #region test_validate_username_with_space [C:2] [TYPE Function]
# @BRIEF Username containing space produces validation error.
def test_validate_username_with_space():
errors = _validate(superset_username="admin user")
assert any("space" in e.lower() for e in errors)
# #endregion test_validate_username_with_space
# #region test_validate_show_only_requires_username [C:2] [TYPE Function]
# @BRIEF show_only_my_dashboards=True without username produces error.
def test_validate_show_only_requires_username():
errors = _validate(superset_username=None, show_only_my_dashboards=True)
assert any("username" in e.lower() and "required" in e.lower() for e in errors)
# #endregion test_validate_show_only_requires_username
# #region test_validate_git_email_missing_at [C:2] [TYPE Function]
# @BRIEF Git email without @ produces validation error.
def test_validate_git_email_missing_at():
errors = _validate(git_email="not-an-email")
assert any("git email" in e.lower() for e in errors)
# #endregion test_validate_git_email_missing_at
# #region test_validate_git_email_with_space [C:2] [TYPE Function]
# @BRIEF Git email containing space produces validation error.
def test_validate_git_email_with_space():
errors = _validate(git_email="admin @x.com")
assert any("git email" in e.lower() for e in errors)
# #endregion test_validate_git_email_with_space
# #region test_validate_git_email_starts_with_at [C:2] [TYPE Function]
# @BRIEF Git email starting with @ produces validation error.
def test_validate_git_email_starts_with_at():
errors = _validate(git_email="@example.com")
assert any("git email" in e.lower() for e in errors)
# #endregion test_validate_git_email_starts_with_at
# #region test_validate_git_email_ends_with_at [C:2] [TYPE Function]
# @BRIEF Git email ending with @ produces validation error.
def test_validate_git_email_ends_with_at():
errors = _validate(git_email="admin@")
assert any("git email" in e.lower() for e in errors)
# #endregion test_validate_git_email_ends_with_at
# #region test_validate_invalid_start_page [C:2] [TYPE Function]
# @BRIEF Unsupported start_page value produces validation error.
def test_validate_invalid_start_page():
errors = _validate(start_page="settings")
assert any("start page" in e.lower() for e in errors)
# #endregion test_validate_invalid_start_page
# #region test_validate_invalid_density [C:2] [TYPE Function]
# @BRIEF Unsupported density value produces validation error.
def test_validate_invalid_density():
errors = _validate(dashboards_table_density="spacious")
assert any("density" in e.lower() for e in errors)
# #endregion test_validate_invalid_density
# #region test_validate_invalid_notification_email [C:2] [TYPE Function]
# @BRIEF Invalid notification email variants all produce validation error.
@pytest.mark.parametrize("bad_email", ["bad-email", "@x.com", "a@", "a b@c.com"])
def test_validate_invalid_notification_email(bad_email):
errors = _validate(email_address=bad_email)
assert any("notification email" in e.lower() for e in errors)
# #endregion test_validate_invalid_notification_email
# #region test_validate_valid_notification_email [C:2] [TYPE Function]
# @BRIEF Valid notification email produces no notification error.
def test_validate_valid_notification_email():
errors = _validate(email_address="user@example.com")
assert not any("notification email" in e.lower() for e in errors)
# #endregion test_validate_valid_notification_email
# #region test_validate_multiple_errors [C:2] [TYPE Function]
# @BRIEF Multiple invalid fields produce multiple accumulated errors.
def test_validate_multiple_errors():
errors = _validate(
superset_username="admin user",
show_only_my_dashboards=True,
git_email="bad",
start_page="unknown",
dashboards_table_density="spacious",
)
assert len(errors) >= 4
# #endregion test_validate_multiple_errors
# --- build_default_preference ---
# #region test_build_default_preference [C:2] [TYPE Function]
# @BRIEF Verify default preference DTO has correct initial values for all fields.
def test_build_default_preference():
pref = build_default_preference("user-1")
assert pref.user_id == "user-1"
assert pref.superset_username is None
assert pref.superset_username_normalized is None
assert pref.show_only_my_dashboards is False
assert pref.show_only_slug_dashboards is True
assert pref.git_username is None
assert pref.git_email is None
assert pref.has_git_personal_access_token is False
assert pref.git_personal_access_token_masked is None
assert pref.start_page == "dashboards"
assert pref.auto_open_task_drawer is True
assert pref.dashboards_table_density == "comfortable"
assert pref.telegram_id is None
assert pref.email_address is None
assert pref.notify_on_fail is True
assert pref.created_at == pref.updated_at
# #endregion test_build_default_preference
# --- normalize_owner_tokens ---
# #region test_owner_tokens_none [C:2] [TYPE Function]
# @BRIEF None input returns empty list.
def test_owner_tokens_none():
assert normalize_owner_tokens(None) == []
# #endregion test_owner_tokens_none
# #region test_owner_tokens_empty [C:2] [TYPE Function]
# @BRIEF Empty iterable returns empty list.
def test_owner_tokens_empty():
assert normalize_owner_tokens([]) == []
# #endregion test_owner_tokens_empty
# #region test_owner_tokens_dict_username [C:2] [TYPE Function]
# @BRIEF Dict with username field produces normalized token.
def test_owner_tokens_dict_username():
tokens = normalize_owner_tokens([{"username": "AdminUser"}])
assert "adminuser" in tokens
# #endregion test_owner_tokens_dict_username
# #region test_owner_tokens_dict_names [C:2] [TYPE Function]
# @BRIEF Dict with first/last name produces individual and combined tokens.
def test_owner_tokens_dict_names():
tokens = normalize_owner_tokens([{"first_name": "John", "last_name": "Doe"}])
assert "john" in tokens
assert "doe" in tokens
assert "john_doe" in tokens
# #endregion test_owner_tokens_dict_names
# #region test_owner_tokens_string [C:2] [TYPE Function]
# @BRIEF Plain string items are normalized directly.
def test_owner_tokens_string():
tokens = normalize_owner_tokens([" Admin "])
assert "admin" in tokens
# #endregion test_owner_tokens_string
# #region test_owner_tokens_dedup [C:2] [TYPE Function]
# @BRIEF Duplicate tokens after normalization are collapsed.
def test_owner_tokens_dedup():
tokens = normalize_owner_tokens(["admin", " Admin "])
assert tokens == ["admin"]
# #endregion test_owner_tokens_dedup
# #region test_owner_tokens_skip_empty [C:2] [TYPE Function]
# @BRIEF Empty/whitespace/None items produce no tokens.
def test_owner_tokens_skip_empty():
assert normalize_owner_tokens(["", " ", None]) == []
# #endregion test_owner_tokens_skip_empty
# #region test_owner_tokens_dict_email [C:2] [TYPE Function]
# @BRIEF Dict with email field produces normalized email token.
def test_owner_tokens_dict_email():
tokens = normalize_owner_tokens([{"email": "User@Example.com"}])
assert "user@example.com" in tokens
# #endregion test_owner_tokens_dict_email
# #region test_owner_tokens_dict_multi [C:2] [TYPE Function]
# @BRIEF Dict with multiple fields produces all valid candidate tokens.
def test_owner_tokens_dict_multi():
owners = [{"username": "jdoe", "first_name": "John", "last_name": "Doe", "email": "john@example.com"}]
tokens = normalize_owner_tokens(owners)
assert "jdoe" in tokens
assert "john" in tokens
assert "doe" in tokens
assert "john@example.com" in tokens
# #endregion test_owner_tokens_dict_multi
# --- exception classes ---
# #region test_exception_profile_validation_error [C:2] [TYPE Function]
# @BRIEF ProfileValidationError stores errors list and has expected message.
def test_exception_profile_validation_error():
err = ProfileValidationError(["field1 bad", "field2 bad"])
assert err.errors == ["field1 bad", "field2 bad"]
assert "validation failed" in str(err).lower()
with pytest.raises(ProfileValidationError):
raise ProfileValidationError(["test"])
# #endregion test_exception_profile_validation_error
# #region test_exception_environment_not_found [C:2] [TYPE Function]
# @BRIEF EnvironmentNotFoundError can be raised and caught as Exception.
def test_exception_environment_not_found():
with pytest.raises(EnvironmentNotFoundError):
raise EnvironmentNotFoundError("env-xyz")
# #endregion test_exception_environment_not_found
# #region test_exception_profile_authorization [C:2] [TYPE Function]
# @BRIEF ProfileAuthorizationError can be raised and caught as Exception.
def test_exception_profile_authorization():
with pytest.raises(ProfileAuthorizationError):
raise ProfileAuthorizationError("cross-user")
# #endregion test_exception_profile_authorization
class TestSanitizeText:
"""sanitize_text — normalize optional text into trimmed form or None."""
def test_none_returns_none(self):
from src.services.profile_utils import sanitize_text
assert sanitize_text(None) is None
def test_empty_returns_none(self):
from src.services.profile_utils import sanitize_text
assert sanitize_text("") is None
def test_whitespace_returns_none(self):
from src.services.profile_utils import sanitize_text
assert sanitize_text(" ") is None
def test_trimmed_text(self):
from src.services.profile_utils import sanitize_text
assert sanitize_text(" hello ") == "hello"
def test_already_trimmed(self):
from src.services.profile_utils import sanitize_text
assert sanitize_text("world") == "world"
class TestSanitizeSecret:
"""sanitize_secret — normalize secret value into trimmed form or None."""
def test_none_returns_none(self):
from src.services.profile_utils import sanitize_secret
assert sanitize_secret(None) is None
def test_empty_returns_none(self):
from src.services.profile_utils import sanitize_secret
assert sanitize_secret("") is None
def test_whitespace_returns_none(self):
from src.services.profile_utils import sanitize_secret
assert sanitize_secret(" ") is None
def test_trimmed_secret(self):
from src.services.profile_utils import sanitize_secret
assert sanitize_secret(" my-token-abc ") == "my-token-abc"
class TestSanitizeUsername:
"""sanitize_username — delegates to sanitize_text."""
def test_none_returns_none(self):
from src.services.profile_utils import sanitize_username
assert sanitize_username(None) is None
def test_valid_username(self):
from src.services.profile_utils import sanitize_username
assert sanitize_username(" admin ") == "admin"
class TestNormalizeUsername:
"""normalize_username — trim + lower."""
def test_none_returns_none(self):
from src.services.profile_utils import normalize_username
assert normalize_username(None) is None
def test_empty_returns_none(self):
from src.services.profile_utils import normalize_username
assert normalize_username("") is None
def test_trim_and_lower(self):
from src.services.profile_utils import normalize_username
assert normalize_username(" AdminUser ") == "adminuser"
def test_already_normalized(self):
from src.services.profile_utils import normalize_username
assert normalize_username("guest") == "guest"
class TestNormalizeStartPage:
"""normalize_start_page — map aliases to canonical values."""
def test_reports_logs_maps_to_reports(self):
from src.services.profile_utils import normalize_start_page
assert normalize_start_page("reports-logs") == "reports"
def test_dashboards_passes_through(self):
from src.services.profile_utils import normalize_start_page
assert normalize_start_page("dashboards") == "dashboards"
def test_datasets_passes_through(self):
from src.services.profile_utils import normalize_start_page
assert normalize_start_page("datasets") == "datasets"
def test_reports_passes_through(self):
from src.services.profile_utils import normalize_start_page
assert normalize_start_page("reports") == "reports"
def test_unknown_defaults_to_dashboards(self):
from src.services.profile_utils import normalize_start_page
assert normalize_start_page("settings") == "dashboards"
def test_none_defaults_to_dashboards(self):
from src.services.profile_utils import normalize_start_page
assert normalize_start_page(None) == "dashboards"
def test_case_insensitive(self):
from src.services.profile_utils import normalize_start_page
assert normalize_start_page("DATASETS") == "datasets"
class TestNormalizeDensity:
"""normalize_density — map aliases to canonical values."""
def test_free_maps_to_comfortable(self):
from src.services.profile_utils import normalize_density
assert normalize_density("free") == "comfortable"
def test_compact_passes_through(self):
from src.services.profile_utils import normalize_density
assert normalize_density("compact") == "compact"
def test_comfortable_passes_through(self):
from src.services.profile_utils import normalize_density
assert normalize_density("comfortable") == "comfortable"
def test_unknown_defaults_to_comfortable(self):
from src.services.profile_utils import normalize_density
assert normalize_density("spacious") == "comfortable"
def test_none_defaults_to_comfortable(self):
from src.services.profile_utils import normalize_density
assert normalize_density(None) == "comfortable"
class TestMaskSecretValue:
"""mask_secret_value — safe display value for secrets."""
def test_none_returns_none(self):
from src.services.profile_utils import mask_secret_value
assert mask_secret_value(None) is None
def test_empty_returns_none(self):
from src.services.profile_utils import mask_secret_value
assert mask_secret_value("") is None
def test_short_secret_returns_asterisks(self):
from src.services.profile_utils import mask_secret_value
assert mask_secret_value("ab") == "***"
def test_exactly_four_chars_returns_asterisks(self):
from src.services.profile_utils import mask_secret_value
assert mask_secret_value("abcd") == "***"
def test_longer_secret_masked(self):
from src.services.profile_utils import mask_secret_value
assert mask_secret_value("my-token-value") == "my***ue"
def test_whitespace_stripped_before_masking(self):
from src.services.profile_utils import mask_secret_value
assert mask_secret_value(" ab ") == "***"
class TestValidateUpdatePayload:
"""validate_update_payload — field-level validation for preference mutation."""
def test_valid_payload_returns_empty(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin",
show_only_my_dashboards=False,
git_email="admin@example.com",
start_page="dashboards",
dashboards_table_density="comfortable",
)
assert errors == []
def test_username_with_spaces(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin user",
show_only_my_dashboards=False,
git_email=None,
start_page="dashboards",
dashboards_table_density="comfortable",
)
assert any("space" in e.lower() for e in errors)
def test_show_only_requires_username_when_true(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username=None,
show_only_my_dashboards=True,
git_email=None,
start_page="dashboards",
dashboards_table_density="comfortable",
)
assert any("username" in e.lower() and "required" in e.lower() for e in errors)
def test_invalid_git_email(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin",
show_only_my_dashboards=False,
git_email="not-an-email",
start_page="dashboards",
dashboards_table_density="comfortable",
)
assert any("email" in e.lower() for e in errors)
def test_git_email_with_spaces(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin",
show_only_my_dashboards=False,
git_email="admin @x.com",
start_page="dashboards",
dashboards_table_density="comfortable",
)
assert any("email" in e.lower() for e in errors)
def test_git_email_starts_with_at(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin",
show_only_my_dashboards=False,
git_email="@example.com",
start_page="dashboards",
dashboards_table_density="comfortable",
)
assert any("email" in e.lower() for e in errors)
def test_invalid_start_page(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin",
show_only_my_dashboards=False,
git_email=None,
start_page="settings",
dashboards_table_density="comfortable",
)
assert any("start page" in e.lower() for e in errors)
def test_invalid_density(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin",
show_only_my_dashboards=False,
git_email=None,
start_page="dashboards",
dashboards_table_density="spacious",
)
assert any("density" in e.lower() for e in errors)
def test_invalid_notification_email(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin",
show_only_my_dashboards=False,
git_email=None,
start_page="dashboards",
dashboards_table_density="comfortable",
email_address="bad-email",
)
assert any("notification email" in e.lower() for e in errors)
def test_valid_notification_email(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin",
show_only_my_dashboards=False,
git_email=None,
start_page="dashboards",
dashboards_table_density="comfortable",
email_address="user@example.com",
)
assert not any("notification email" in e.lower() for e in errors)
def test_multiple_errors(self):
from src.services.profile_utils import validate_update_payload
errors = validate_update_payload(
superset_username="admin user",
show_only_my_dashboards=True,
git_email="bad",
start_page="unknown",
dashboards_table_density="spacious",
)
assert len(errors) >= 4
class TestBuildDefaultPreference:
"""build_default_preference — default ProfilePreference for unconfigured users."""
def test_returns_profile_preference(self):
from src.services.profile_utils import build_default_preference
result = build_default_preference("user-1")
assert result.user_id == "user-1"
assert result.superset_username is None
assert result.show_only_my_dashboards is False
assert result.show_only_slug_dashboards is True
assert result.start_page == "dashboards"
assert result.dashboards_table_density == "comfortable"
assert result.has_git_personal_access_token is False
assert result.notify_on_fail is True
class TestNormalizeOwnerTokens:
"""normalize_owner_tokens — deduplicated lower-cased tokens from owners."""
def test_none_returns_empty(self):
from src.services.profile_utils import normalize_owner_tokens
assert normalize_owner_tokens(None) == []
def test_empty_list(self):
from src.services.profile_utils import normalize_owner_tokens
assert normalize_owner_tokens([]) == []
def test_dict_owner_with_username(self):
from src.services.profile_utils import normalize_owner_tokens
owners = [{"username": "AdminUser"}]
tokens = normalize_owner_tokens(owners)
assert "adminuser" in tokens
def test_dict_owner_with_full_name(self):
from src.services.profile_utils import normalize_owner_tokens
owners = [{"first_name": "John", "last_name": "Doe"}]
tokens = normalize_owner_tokens(owners)
assert "john" in tokens
assert "doe" in tokens
assert "john_doe" or "john doe" in tokens
def test_string_owner(self):
from src.services.profile_utils import normalize_owner_tokens
owners = [" Admin "]
tokens = normalize_owner_tokens(owners)
assert "admin" in tokens
def test_dedup(self):
from src.services.profile_utils import normalize_owner_tokens
owners = ["admin", " Admin "]
tokens = normalize_owner_tokens(owners)
assert tokens == ["admin"]
def test_skip_empty(self):
from src.services.profile_utils import normalize_owner_tokens
owners = ["", " ", None]
tokens = normalize_owner_tokens(owners)
assert tokens == []
def test_dict_owner_with_email(self):
from src.services.profile_utils import normalize_owner_tokens
owners = [{"email": "User@Example.com"}]
tokens = normalize_owner_tokens(owners)
assert "user@example.com" in tokens
def test_dict_owner_multiple_candidates(self):
from src.services.profile_utils import normalize_owner_tokens
owners = [{"username": "jdoe", "first_name": "John", "last_name": "Doe", "email": "john@example.com"}]
tokens = normalize_owner_tokens(owners)
assert "jdoe" in tokens
assert "john" in tokens
assert "doe" in tokens
assert "john@example.com" in tokens
# #endregion Test.ProfileUtils

View File

@@ -1,6 +1,9 @@
# #region Test.SecurityBadgeService [C:3] [TYPE Module] [SEMANTICS test,profile,security,permissions,badges]
# @BRIEF Tests for security_badge_service.py — role extraction, permission pairs, security summary.
# #region Test.SecurityBadgeService [C:3] [TYPE Module] [SEMANTICS test,security,badge]
# @BRIEF Verify SecurityBadgeService contracts — role extraction, permission pairs, security summary.
# @RELATION BINDS_TO -> [SecurityBadgeService]
# @TEST_EDGE: missing_field -> empty/None roles, permissions, auth_source handled gracefully
# @TEST_EDGE: invalid_type -> empty role names, whitespace-only names sanitized
# @TEST_EDGE: external_fail -> discover_declared_permissions raises, fallback to user permissions
from pathlib import Path
import sys
@@ -10,131 +13,152 @@ sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from unittest.mock import MagicMock, patch
import pytest
def make_user(**kwargs):
"""Helper to create a mock User with roles."""
# #region _make_user [C:1] [TYPE Function]
def _make_user(**kwargs):
from src.models.auth import User
user = MagicMock(spec=User)
for k, v in kwargs.items():
setattr(user, k, v)
return user
def make_role(name, permissions=None):
# #endregion _make_user
# #region _make_role [C:1] [TYPE Function]
def _make_role(name, permissions=None):
role = MagicMock()
role.name = name
role.permissions = permissions or []
return role
def make_permission(resource, action):
# #endregion _make_role
# #region _make_permission [C:1] [TYPE Function]
def _make_permission(resource, action):
perm = MagicMock()
perm.resource = resource
perm.action = action
return perm
# #endregion _make_permission
class TestCollectUserPermissionPairs:
"""_collect_user_permission_pairs — extract (resource, ACTION) tuples."""
# #region test_no_roles_returns_empty [C:2] [TYPE Function]
# @BRIEF User with empty roles list yields no permission pairs.
def test_no_roles_returns_empty(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
user = make_user(roles=[])
user = _make_user(roles=[])
result = svc._collect_user_permission_pairs(user)
assert result == set()
# #endregion test_no_roles_returns_empty
# #region test_role_with_permissions [C:2] [TYPE Function]
# @BRIEF Role permissions are normalized to (resource, ACTION) tuples.
def test_role_with_permissions(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
perms = [make_permission("dashboard", "read"), make_permission("dataset", "write")]
role = make_role("Editor", perms)
user = make_user(roles=[role])
perms = [_make_permission("dashboard", "read"), _make_permission("dataset", "write")]
role = _make_role("Editor", perms)
user = _make_user(roles=[role])
result = svc._collect_user_permission_pairs(user)
assert ("dashboard", "READ") in result
assert ("dataset", "WRITE") in result
# #endregion test_role_with_permissions
# #region test_permission_without_resource_skipped [C:2] [TYPE Function]
# @BRIEF Permissions with empty or None resource are excluded from collection.
def test_permission_without_resource_skipped(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
perms = [make_permission("", "read"), make_permission(None, "write")]
role = make_role("Viewer", perms)
user = make_user(roles=[role])
perms = [_make_permission("", "read"), _make_permission(None, "write")]
role = _make_role("Viewer", perms)
user = _make_user(roles=[role])
result = svc._collect_user_permission_pairs(user)
assert result == set()
# #endregion test_permission_without_resource_skipped
# #region test_multiple_roles_dedup [C:2] [TYPE Function]
# @BRIEF Duplicate permissions across roles are deduplicated in result set.
def test_multiple_roles_dedup(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
p1 = make_permission("dashboard", "read")
role1 = make_role("Admin", [p1])
role2 = make_role("Editor", [p1])
user = make_user(roles=[role1, role2])
p1 = _make_permission("dashboard", "read")
role1 = _make_role("Admin", [p1])
role2 = _make_role("Editor", [p1])
user = _make_user(roles=[role1, role2])
result = svc._collect_user_permission_pairs(user)
assert len(result) == 1
# #endregion test_multiple_roles_dedup
class TestBuildSecuritySummary:
"""build_security_summary — full security snapshot."""
# #region test_no_roles [C:2] [TYPE Function]
# @BRIEF User with no roles produces empty roles list and None current_role.
def test_no_roles(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
user = make_user(roles=[], auth_source="database")
user = _make_user(roles=[], auth_source="database")
summary = svc.build_security_summary(user)
assert summary.read_only is True
assert summary.roles == []
assert summary.current_role is None
assert summary.auth_source == "database"
# #endregion test_no_roles
# #region test_single_role [C:2] [TYPE Function]
# @BRIEF Single non-admin role becomes current_role and appears in sorted roles list.
def test_single_role(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
role = make_role("Viewer")
user = make_user(roles=[role], auth_source="ldap")
role = _make_role("Viewer")
user = _make_user(roles=[role], auth_source="ldap")
summary = svc.build_security_summary(user)
assert summary.roles == ["Viewer"]
assert summary.current_role == "Viewer"
# #endregion test_single_role
# #region test_admin_role_detected [C:2] [TYPE Function]
# @BRIEF Admin role is detected and set as current_role.
def test_admin_role_detected(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
role = make_role("Admin")
user = make_user(roles=[role], auth_source="database")
role = _make_role("Admin")
user = _make_user(roles=[role], auth_source="database")
summary = svc.build_security_summary(user)
assert "Admin" in summary.roles
assert summary.current_role == "Admin"
# #endregion test_admin_role_detected
# #region test_roles_sorted [C:2] [TYPE Function]
# @BRIEF Roles in summary are deterministically sorted alphabetically.
def test_roles_sorted(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
role_z = make_role("ZViewer")
role_a = make_role("AAdmin")
user = make_user(roles=[role_z, role_a])
role_z = _make_role("ZViewer")
role_a = _make_role("AAdmin")
user = _make_user(roles=[role_z, role_a])
summary = svc.build_security_summary(user)
assert summary.roles == ["AAdmin", "ZViewer"]
# #endregion test_roles_sorted
# #region test_discover_permissions_fallback [C:2] [TYPE Function]
# @BRIEF When discover_declared_permissions raises, fallback to user permission pairs.
def test_discover_permissions_fallback(self):
"""When discover_declared_permissions raises, fallback to user permissions."""
from src.services.security_badge_service import SecurityBadgeService
perms = [make_permission("dashboard", "read")]
role = make_role("Editor", perms)
user = make_user(roles=[role])
perms = [_make_permission("dashboard", "read")]
role = _make_role("Editor", perms)
user = _make_user(roles=[role])
with patch("src.services.security_badge_service.discover_declared_permissions",
side_effect=RuntimeError("Plugin load failed")):
svc = SecurityBadgeService(plugin_loader=MagicMock())
summary = svc.build_security_summary(user)
# Should still produce permission states
assert len(summary.permissions) >= 0
# #endregion test_discover_permissions_fallback
# #region test_is_admin_allows_all [C:2] [TYPE Function]
# @BRIEF Admin user sees all declared permissions as allowed=True.
def test_is_admin_allows_all(self):
"""Admin user sees all declared permissions as allowed."""
from src.services.security_badge_service import SecurityBadgeService
perms = [make_permission("dashboard", "read")]
role = make_role("Admin", perms)
user = make_user(roles=[role])
perms = [_make_permission("dashboard", "read")]
role = _make_role("Admin", perms)
user = _make_user(roles=[role])
with patch("src.services.security_badge_service.discover_declared_permissions",
return_value={("dashboard", "READ"), ("dataset", "WRITE")}):
svc = SecurityBadgeService()
@@ -142,66 +166,134 @@ class TestBuildSecuritySummary:
admin_perm = next((p for p in summary.permissions if p.key == "dashboard"), None)
assert admin_perm is not None
assert admin_perm.allowed is True
# #endregion test_is_admin_allows_all
# #region test_non_admin_permission_allowed_and_denied [C:2] [TYPE Function]
# @BRIEF Non-admin user: owned permissions allowed=True, others allowed=False.
def test_non_admin_permission_allowed_and_denied(self):
from src.services.security_badge_service import SecurityBadgeService
perms = [_make_permission("dashboard", "read")]
role = _make_role("Editor", perms)
user = _make_user(roles=[role], auth_source="LOCAL")
with patch("src.services.security_badge_service.discover_declared_permissions",
return_value={("dashboard", "READ"), ("dataset", "WRITE")}):
svc = SecurityBadgeService()
summary = svc.build_security_summary(user)
perm_map = {p.key: p.allowed for p in summary.permissions}
assert perm_map["dashboard"] is True
assert perm_map["dataset:write"] is False
# #endregion test_non_admin_permission_allowed_and_denied
# #region test_role_source_equals_auth_source [C:2] [TYPE Function]
# @BRIEF role_source field mirrors auth_source from user.
def test_role_source_equals_auth_source(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
user = _make_user(roles=[], auth_source="ADFS")
summary = svc.build_security_summary(user)
assert summary.role_source == "ADFS"
# #endregion test_role_source_equals_auth_source
# #region test_auth_source_none [C:2] [TYPE Function]
# @BRIEF User with None auth_source produces None in summary.
def test_auth_source_none(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
user = _make_user(roles=[], auth_source=None)
summary = svc.build_security_summary(user)
assert summary.auth_source is None
# #endregion test_auth_source_none
# #region test_user_roles_none [C:2] [TYPE Function]
# @BRIEF User with roles=None is treated as empty roles list.
def test_user_roles_none(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
user = _make_user(roles=None, auth_source="LOCAL")
summary = svc.build_security_summary(user)
assert summary.roles == []
assert summary.current_role is None
# #endregion test_user_roles_none
# #region test_permission_key_format_read [C:2] [TYPE Function]
# @BRIEF READ action produces resource-only key (no action suffix).
def test_permission_key_format_read(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
key = svc._format_permission_key("dashboard", "READ")
assert key == "dashboard"
# #endregion test_permission_key_format_read
# #region test_permission_key_format_write [C:2] [TYPE Function]
# @BRIEF Non-READ action produces resource:action key format.
def test_permission_key_format_write(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
key = svc._format_permission_key("dataset", "WRITE")
assert key == "dataset:write"
# #endregion test_permission_key_format_write
class TestFormatPermissionKey:
"""_format_permission_key — compact UI key format."""
# #region test_read_omits_action [C:2] [TYPE Function]
# @BRIEF READ action yields bare resource string without action suffix.
def test_read_omits_action(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
assert svc._format_permission_key("dashboard", "READ") == "dashboard"
# #endregion test_read_omits_action
# #region test_write_includes_action [C:2] [TYPE Function]
# @BRIEF Non-READ action yields resource:lowercase_action compound key.
def test_write_includes_action(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
assert svc._format_permission_key("dataset", "WRITE") == "dataset:write"
# #endregion test_write_includes_action
# #region test_empty_resource [C:2] [TYPE Function]
# @BRIEF Empty resource produces a valid string key without raising.
def test_empty_resource(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
result = svc._format_permission_key("", "READ")
assert result is not None
assert isinstance(result, str)
# #endregion test_empty_resource
class TestPermissionEdgeCases:
"""Edge cases for permission processing."""
# #region test_empty_role_name_skipped [C:2] [TYPE Function]
# @BRIEF Roles with empty or None names are excluded from summary roles list.
def test_empty_role_name_skipped(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
role_empty = make_role("")
role_none = make_role(None)
user = make_user(roles=[role_empty, role_none])
role_empty = _make_role("")
role_none = _make_role(None)
user = _make_user(roles=[role_empty, role_none])
summary = svc.build_security_summary(user)
assert summary.roles == []
# #endregion test_empty_role_name_skipped
# #region test_role_name_with_whitespace_sanitized [C:2] [TYPE Function]
# @BRIEF Role names with surrounding whitespace are trimmed in summary.
def test_role_name_with_whitespace_sanitized(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
role = make_role(" Editor ")
user = make_user(roles=[role])
role = _make_role(" Editor ")
user = _make_user(roles=[role])
summary = svc.build_security_summary(user)
assert "Editor" in summary.roles
# #endregion test_role_name_with_whitespace_sanitized
# #region test_collect_with_none_permissions [C:2] [TYPE Function]
# @BRIEF Role with permissions=None yields empty permission set without error.
def test_collect_with_none_permissions(self):
from src.services.security_badge_service import SecurityBadgeService
svc = SecurityBadgeService()
role = make_role("Viewer", permissions=None)
user = make_user(roles=[role])
role = _make_role("Viewer", permissions=None)
user = _make_user(roles=[role])
result = svc._collect_user_permission_pairs(user)
assert result == set()
# #endregion test_collect_with_none_permissions
# #endregion Test.SecurityBadgeService

View File

@@ -1,6 +1,10 @@
# #region Test.SupersetLookupService [C:3] [TYPE Module] [SEMANTICS test,superset,account,lookup,environment]
# @BRIEF Tests for superset_lookup_service.py — resolve environment, lookup accounts, degradation fallback.
# #region Test.SupersetLookupService [C:3] [TYPE Module] [SEMANTICS test,superset,lookup]
# @BRIEF Verify SupersetLookupService — environment resolution, success lookup, degradation fallback, sort normalization.
# @RELATION BINDS_TO -> [SupersetLookupService]
# @TEST_EDGE: environment_not_found -> raises EnvironmentNotFoundError
# @TEST_EDGE: external_fail -> returns degraded response with warning
# @TEST_EDGE: invalid_sort_column -> falls back to "username"
# @TEST_EDGE: invalid_sort_order -> falls back to "desc"
from pathlib import Path
import sys
@@ -13,10 +17,27 @@ import pytest
from src.schemas.profile import SupersetAccountLookupRequest
class MockEnvironment:
class _Env:
def __init__(self, env_id):
self.id = env_id
self.name = f"Env-{env_id}"
def _make_request(**overrides):
defaults = dict(
environment_id="env-1", search=None, page_index=0,
page_size=20, sort_column="username", sort_order="desc",
)
defaults.update(overrides)
req = MagicMock(spec=SupersetAccountLookupRequest)
for k, v in defaults.items():
setattr(req, k, v)
return req
def _wire_client(mock_client_cls):
client = MagicMock()
client.client = MagicMock()
mock_client_cls.return_value = client
class TestSupersetLookupService:
@@ -25,10 +46,7 @@ class TestSupersetLookupService:
@pytest.fixture
def config_manager(self):
cm = MagicMock()
cm.get_environments.return_value = [
MockEnvironment("env-1"),
MockEnvironment("env-2"),
]
cm.get_environments.return_value = [_Env("env-1"), _Env("env-2")]
return cm
@pytest.fixture
@@ -36,191 +54,112 @@ class TestSupersetLookupService:
from src.services.superset_lookup_service import SupersetLookupService
return SupersetLookupService(config_manager)
def test_init(self, config_manager):
# #region test_init_stores_config [C:2] [TYPE Function]
# @BRIEF Constructor stores config_manager reference.
def test_init_stores_config(self, config_manager):
from src.services.superset_lookup_service import SupersetLookupService
svc = SupersetLookupService(config_manager)
assert svc.config_manager is config_manager
# #endregion test_init_stores_config
def test_resolve_environment_found(self, service):
# #region test_resolve_env_found [C:2] [TYPE Function]
# @BRIEF Returns environment when id matches.
def test_resolve_env_found(self, service):
env = service._resolve_environment("env-1")
assert env is not None
assert env.id == "env-1"
assert env is not None and env.id == "env-1"
# #endregion test_resolve_env_found
def test_resolve_environment_not_found(self, service):
env = service._resolve_environment("non-existent")
assert env is None
def test_resolve_environment_case_sensitive(self, service):
env = service._resolve_environment("ENV-1")
assert env is None
# #region test_resolve_env_not_found [C:2] [TYPE Function]
# @BRIEF Returns None for unknown environment id.
def test_resolve_env_not_found(self, service):
assert service._resolve_environment("non-existent") is None
# #endregion test_resolve_env_not_found
# #region test_lookup_env_not_found_raises [C:2] [TYPE Function]
# @BRIEF Raises EnvironmentNotFoundError when environment_id is unknown.
@pytest.mark.asyncio
async def test_lookup_environment_not_found(self, service):
async def test_lookup_env_not_found_raises(self, service):
from src.services.superset_lookup_service import EnvironmentNotFoundError
request = MagicMock(spec=SupersetAccountLookupRequest)
request.environment_id = "non-existent"
request.search = None
request.page_index = 0
request.page_size = 20
request.sort_column = "username"
request.sort_order = "desc"
with pytest.raises(EnvironmentNotFoundError, match="Environment 'non-existent' not found"):
await service.lookup_superset_accounts(MagicMock(), request)
with pytest.raises(EnvironmentNotFoundError, match="non-existent"):
await service.lookup_superset_accounts(MagicMock(), _make_request(environment_id="non-existent"))
# #endregion test_lookup_env_not_found_raises
# #region test_lookup_success [C:2] [TYPE Function]
# @BRIEF Happy path: adapter returns items → success response with candidates.
@pytest.mark.asyncio
async def test_lookup_success(self, service, config_manager):
request = MagicMock(spec=SupersetAccountLookupRequest)
request.environment_id = "env-1"
request.search = None
request.page_index = 0
request.page_size = 20
request.sort_column = "username"
request.sort_order = "desc"
current_user = MagicMock()
current_user.id = "user-1"
mock_adapter = AsyncMock()
mock_adapter.get_users_page.return_value = {
"items": [
{"environment_id": "env-1", "username": "admin", "display_name": "Admin", "email": "admin@x.com"},
],
async def test_lookup_success(self, service):
adapter = AsyncMock()
adapter.get_users_page.return_value = {
"items": [{"environment_id": "env-1", "username": "admin", "display_name": "Admin", "email": "a@x.com"}],
"total": 1,
}
with patch("src.services.superset_lookup_service.AsyncSupersetClient") as mc, \
patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter", return_value=adapter):
_wire_client(mc)
resp = await service.lookup_superset_accounts(MagicMock(id="u1"), _make_request())
with (
patch("src.services.superset_lookup_service.AsyncSupersetClient") as mock_client_cls,
patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter") as mock_adapter_cls,
):
mock_client = MagicMock()
mock_client.client = MagicMock()
mock_client_cls.return_value = mock_client
mock_adapter_cls.return_value = mock_adapter
response = await service.lookup_superset_accounts(current_user, request)
assert response.status == "success"
assert response.environment_id == "env-1"
assert len(response.items) == 1
assert response.items[0].username == "admin"
assert response.warning is None
assert resp.status == "success"
assert resp.environment_id == "env-1"
assert resp.total == 1
assert len(resp.items) == 1
assert resp.items[0].username == "admin"
assert resp.warning is None
# #endregion test_lookup_success
# #region test_lookup_degraded_on_exception [C:2] [TYPE Function]
# @BRIEF External failure → degraded response with warning, empty items.
@pytest.mark.asyncio
async def test_lookup_degraded_on_exception(self, service, config_manager):
request = MagicMock(spec=SupersetAccountLookupRequest)
request.environment_id = "env-1"
request.search = None
request.page_index = 0
request.page_size = 20
request.sort_column = "username"
request.sort_order = "desc"
async def test_lookup_degraded_on_exception(self, service):
with patch("src.services.superset_lookup_service.AsyncSupersetClient", side_effect=ConnectionError("down")):
resp = await service.lookup_superset_accounts(MagicMock(id="u1"), _make_request())
current_user = MagicMock()
current_user.id = "user-1"
with (
patch("src.services.superset_lookup_service.AsyncSupersetClient",
side_effect=ConnectionError("Superset unreachable")),
):
response = await service.lookup_superset_accounts(current_user, request)
assert response.status == "degraded"
assert response.environment_id == "env-1"
assert len(response.items) == 0
assert response.warning is not None
assert "Cannot load" in response.warning
assert resp.status == "degraded"
assert resp.total == 0
assert resp.items == []
assert "Cannot load" in resp.warning
# #endregion test_lookup_degraded_on_exception
# #region test_sort_normalization [C:2] [TYPE Function]
# @BRIEF Invalid sort_column → "username"; invalid sort_order → "desc"; None → defaults.
@pytest.mark.asyncio
async def test_lookup_invalid_sort_column_defaults_username(self, service, config_manager):
request = MagicMock(spec=SupersetAccountLookupRequest)
request.environment_id = "env-1"
request.search = None
request.page_index = 0
request.page_size = 20
request.sort_column = "invalid_col"
request.sort_order = "desc"
async def test_sort_normalization(self, service):
adapter = AsyncMock()
adapter.get_users_page.return_value = {"items": [], "total": 0}
current_user = MagicMock()
current_user.id = "user-1"
cases = [
({"sort_column": "invalid_col", "sort_order": "desc"}, "username", "desc"),
({"sort_column": "username", "sort_order": "invalid"}, "username", "desc"),
({"sort_column": None, "sort_order": None}, "username", "desc"),
]
for overrides, expected_col, expected_order in cases:
adapter.reset_mock()
with patch("src.services.superset_lookup_service.AsyncSupersetClient") as mc, \
patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter", return_value=adapter):
_wire_client(mc)
await service.lookup_superset_accounts(MagicMock(id="u1"), _make_request(**overrides))
mock_adapter = AsyncMock()
mock_adapter.get_users_page.return_value = {"items": [], "total": 0}
with (
patch("src.services.superset_lookup_service.AsyncSupersetClient") as mock_client_cls,
patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter") as mock_adapter_cls,
):
mock_client = MagicMock()
mock_client.client = MagicMock()
mock_client_cls.return_value = mock_client
mock_adapter_cls.return_value = mock_adapter
response = await service.lookup_superset_accounts(current_user, request)
assert response.status == "success"
# Should have used default sort column "username"
mock_adapter.get_users_page.assert_called_once()
call_kwargs = mock_adapter.get_users_page.call_args.kwargs or mock_adapter.get_users_page.call_args[1]
# Sort is passed as kwargs — verify
assert call_kwargs.get("sort_column", mock_adapter.get_users_page.call_args.kwargs.get("sort_column")) is None or True
kw = adapter.get_users_page.call_args.kwargs
assert kw["sort_column"] == expected_col, f"Failed for overrides={overrides}"
assert kw["sort_order"] == expected_order, f"Failed for overrides={overrides}"
# #endregion test_sort_normalization
# #region test_success_total_fallback_to_items_len [C:2] [TYPE Function]
# @BRIEF When adapter omits 'total', response total falls back to len(items).
@pytest.mark.asyncio
async def test_lookup_invalid_sort_order_defaults_desc(self, service, config_manager):
request = MagicMock(spec=SupersetAccountLookupRequest)
request.environment_id = "env-1"
request.search = None
request.page_index = 0
request.page_size = 20
request.sort_column = "username"
request.sort_order = "invalid"
async def test_success_total_fallback_to_items_len(self, service):
adapter = AsyncMock()
adapter.get_users_page.return_value = {
"items": [
{"environment_id": "env-1", "username": "a"},
{"environment_id": "env-1", "username": "b"},
],
}
with patch("src.services.superset_lookup_service.AsyncSupersetClient") as mc, \
patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter", return_value=adapter):
_wire_client(mc)
resp = await service.lookup_superset_accounts(MagicMock(id="u1"), _make_request())
current_user = MagicMock()
current_user.id = "user-1"
mock_adapter = AsyncMock()
mock_adapter.get_users_page.return_value = {"items": [], "total": 0}
with (
patch("src.services.superset_lookup_service.AsyncSupersetClient") as mock_client_cls,
patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter") as mock_adapter_cls,
):
mock_client = MagicMock()
mock_client.client = MagicMock()
mock_client_cls.return_value = mock_client
mock_adapter_cls.return_value = mock_adapter
response = await service.lookup_superset_accounts(current_user, request)
assert response.status == "success"
mock_adapter.get_users_page.assert_called_once()
@pytest.mark.asyncio
async def test_lookup_empty_sort_defaults(self, service, config_manager):
request = MagicMock(spec=SupersetAccountLookupRequest)
request.environment_id = "env-1"
request.search = None
request.page_index = 0
request.page_size = 20
request.sort_column = None
request.sort_order = None
current_user = MagicMock()
current_user.id = "user-1"
mock_adapter = AsyncMock()
mock_adapter.get_users_page.return_value = {"items": [], "total": 0}
with (
patch("src.services.superset_lookup_service.AsyncSupersetClient") as mock_client_cls,
patch("src.services.superset_lookup_service.SupersetAccountLookupAdapter") as mock_adapter_cls,
):
mock_client = MagicMock()
mock_client.client = MagicMock()
mock_client_cls.return_value = mock_client
mock_adapter_cls.return_value = mock_adapter
response = await service.lookup_superset_accounts(current_user, request)
assert response.status == "success"
assert resp.status == "success"
assert resp.total == 2
# #endregion test_success_total_fallback_to_items_len
# #endregion Test.SupersetLookupService

View File

@@ -304,7 +304,7 @@ services:
LLM_MODEL: \${LLM_MODEL:-gpt-4o}
FASTAPI_URL: http://backend:8000
JWT_SECRET: \${JWT_SECRET:-super-secret-key}
SERVICE_TOKEN_SECRET: \${SERVICE_TOKEN_SECRET:-agent-service-secret}
SERVICE_JWT: \${SERVICE_JWT:-agent-service-secret}
DATABASE_URL: postgresql+psycopg2://\${POSTGRES_USER:-postgres}:\${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@\${POSTGRES_HOST:?Set POSTGRES_HOST}:\${POSTGRES_PORT:-5432}/\${POSTGRES_DB:-ss_tools}
GRADIO_SERVER_PORT: 7860
ports:

View File

@@ -97,7 +97,7 @@ services:
LLM_MODEL: ${LLM_MODEL:-gpt-4o}
FASTAPI_URL: http://backend:8000
JWT_SECRET: ${JWT_SECRET:-super-secret-key}
SERVICE_TOKEN_SECRET: ${SERVICE_TOKEN_SECRET:-agent-service-secret}
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
DATABASE_URL: postgresql+psycopg2://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env.enterprise-clean}@${POSTGRES_HOST:?Set POSTGRES_HOST in .env.enterprise-clean}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-ss_tools}
GRADIO_SERVER_PORT: 7860
ports:

View File

@@ -60,7 +60,7 @@ services:
LLM_MODEL: ${LLM_MODEL:-gpt-4o}
FASTAPI_URL: http://backend:8000
JWT_SECRET: ${JWT_SECRET:-super-secret-key}
SERVICE_TOKEN_SECRET: ${SERVICE_TOKEN_SECRET:-agent-service-secret}
SERVICE_JWT: ${SERVICE_JWT:-agent-service-secret}
DATABASE_URL: postgresql+psycopg2://postgres:postgres@db:5432/ss_tools
GRADIO_SERVER_PORT: 7860
ports:

View File

@@ -1,4 +1,17 @@
# Dockerfile.agent — Gradio + LangGraph agent backend
# #region docker.agent.Dockerfile [C:3] [TYPE Module] [SEMANTICS docker,agent,build]
# @BRIEF Agent Dockerfile — Python 3.11 slim + Gradio + LangGraph agent.
# @LAYER Infrastructure
# @RELATION DEPENDS_ON -> [EXT:path:backend/requirements.txt]
# @PRE Docker builder context — корень проекта. backend/ и docker/ доступны.
# @POST Образ с Gradio-агентом, LangGraph, минимальной копией backend/src/.
# @RATIONALE Агенту нужен только src.agent.* и src.core.cot_logger (чистый stdlib).
# Полный трассировщик импортов показал:
# src.agent.app → src.agent.{context,document_parser,langgraph_setup,middleware,tools}
# → src.core.cot_logger (stdlib-only, нет бизнес-логики)
# Ни src.models, ни src.api, ни src.services, ни src.plugins НЕ импортируются.
# @REJECTED COPY backend/ /app/backend/ rejected — нарушает принцип изоляции контейнеров.
# COPY только backend/src/agent/ недостаточно — нужен src.core.cot_logger.
FROM python:3.11-slim
WORKDIR /app
@@ -7,13 +20,27 @@ WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libgl1 libglib2.0-0 && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
gradio>=5.0 langgraph>=0.2 langchain-core>=0.3 \
langchain-openai>=0.3 langgraph-checkpoint-postgres pdfplumber
# Python dependencies — используем backend/requirements.txt (содержит httpx, gradio,
# langgraph, langchain-openai, pdfplumber и все транзитивные зависимости)
COPY backend/requirements.txt /app/backend/requirements.txt
RUN pip install --no-cache-dir -r /app/backend/requirements.txt
# ── Минимальная копия backend/src/ ──────────────────────────────
# Только то, что реально импортирует агент (трассировка 2026-06-14):
# src/__init__.py — пакетный корень src
# src/core/__init__.py — пакетный корень src.core
# src/core/cot_logger.py — разделяемый логгер (stdlib-only)
# src/agent/*.py — сам агент (8 .py-файлов, плоская структура)
# Ничего из api/, models/, schemas/, services/, plugins/ — не копируем.
COPY backend/src/__init__.py /app/backend/src/__init__.py
COPY backend/src/core/__init__.py /app/backend/src/core/__init__.py
COPY backend/src/core/cot_logger.py /app/backend/src/core/cot_logger.py
COPY backend/src/agent/ /app/backend/src/agent/
# Gradio server
ENV GRADIO_SERVER_NAME=0.0.0.0
ENV GRADIO_SERVER_PORT=7860
CMD ["python", "-m", "backend.src.agent.run"]
WORKDIR /app/backend
CMD ["python", "-m", "src.agent.run"]
# #endregion docker.agent.Dockerfile

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config';
import { svelte } from '@sveltejs/vite-plugin-svelte';
export default defineConfig({
plugins: [svelte()],
test: {
globals: true,
environment: 'jsdom',
coverage: {
provider: 'istanbul',
reporter: ['text', 'lcovonly'],
clean: false,
include: ['src/lib/**/*.{js,ts,svelte}'],
exclude: ['**/__tests__/**', '**/*.test.*', '**/*.spec.*', '**/locales/**', '**/mocks/**'],
},
},
});