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:
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
386
backend/tests/api/test_settings_consolidated.py
Normal file
386
backend/tests/api/test_settings_consolidated.py
Normal 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
|
||||
@@ -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):
|
||||
|
||||
341
backend/tests/core/superset_client/test_client_base.py
Normal file
341
backend/tests/core/superset_client/test_client_base.py
Normal 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
|
||||
327
backend/tests/core/superset_client/test_client_charts.py
Normal file
327
backend/tests/core/superset_client/test_client_charts.py
Normal 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
|
||||
395
backend/tests/core/superset_client/test_client_dashboards.py
Normal file
395
backend/tests/core/superset_client/test_client_dashboards.py
Normal 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
|
||||
@@ -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
|
||||
858
backend/tests/core/test_core_config_manager.py
Normal file
858
backend/tests/core/test_core_config_manager.py
Normal 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
|
||||
563
backend/tests/core/test_core_database.py
Normal file
563
backend/tests/core/test_core_database.py
Normal 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
|
||||
928
backend/tests/core/test_core_db_executor.py
Normal file
928
backend/tests/core/test_core_db_executor.py
Normal 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
|
||||
561
backend/tests/core/test_core_fileio.py
Normal file
561
backend/tests/core/test_core_fileio.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
910
backend/tests/core/test_scheduler.py
Normal file
910
backend/tests/core/test_scheduler.py
Normal 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
|
||||
176
backend/tests/core/test_timezone.py
Normal file
176
backend/tests/core/test_timezone.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
190
backend/tests/models/test_filter_state.py
Normal file
190
backend/tests/models/test_filter_state.py
Normal 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
|
||||
339
backend/tests/services/git/test_git_branch.py
Normal file
339
backend/tests/services/git/test_git_branch.py
Normal 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
|
||||
480
backend/tests/services/git/test_git_gitea.py
Normal file
480
backend/tests/services/git/test_git_gitea.py
Normal 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
|
||||
435
backend/tests/services/git/test_git_merge.py
Normal file
435
backend/tests/services/git/test_git_merge.py
Normal 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
|
||||
288
backend/tests/services/git/test_git_sync.py
Normal file
288
backend/tests/services/git/test_git_sync.py
Normal 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
|
||||
347
backend/tests/services/git/test_git_url.py
Normal file
347
backend/tests/services/git/test_git_url.py
Normal 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
|
||||
289
backend/tests/services/test_auth_service.py
Normal file
289
backend/tests/services/test_auth_service.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
build.sh
2
build.sh
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
17
frontend/vitest.coverage.tmp.config.js
Normal file
17
frontend/vitest.coverage.tmp.config.js
Normal 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/**'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user