Refactor backend: Split models and proxmox_client into modules, add alembic
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m27s
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m27s
This commit is contained in:
@@ -1,10 +1,8 @@
|
||||
"""
|
||||
Shared pytest fixtures.
|
||||
Shared pytest fixtures and test setup.
|
||||
|
||||
The DB engine is constructed at import time in ``app.db``. To keep tests
|
||||
hermetic we set environment variables BEFORE importing the application.
|
||||
The engine itself is created lazily-connected, so as long as no test issues
|
||||
a real query the configured URL is irrelevant.
|
||||
This file overrides the production PostgreSQL database setup with an in-memory
|
||||
SQLite database and configures mocks for all Proxmox operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,16 +15,15 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# --- Environment must be set BEFORE importing app.* ---------------------------
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test")
|
||||
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
|
||||
os.environ.setdefault("ADMIN_PASSWORD", "test-admin-password")
|
||||
os.environ.setdefault("LOG_LEVEL", "WARNING")
|
||||
# Use a PostgreSQL URL during import to avoid SQLAlchemy pool parameter TypeErrors (like max_overflow) with SQLite.
|
||||
os.environ["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test"
|
||||
os.environ["SECRET_KEY"] = "test-secret-key-do-not-use-in-prod"
|
||||
os.environ["ADMIN_PASSWORD"] = "test-admin-password"
|
||||
os.environ["LOG_LEVEL"] = "WARNING"
|
||||
|
||||
# Generate a deterministic Fernet key for tests so encrypt/decrypt are stable.
|
||||
from cryptography.fernet import Fernet # noqa: E402
|
||||
|
||||
os.environ.setdefault("FERNET_KEY", Fernet.generate_key().decode())
|
||||
os.environ["FERNET_KEY"] = Fernet.generate_key().decode()
|
||||
|
||||
# Ensure the backend root is on sys.path so ``import app`` works regardless of
|
||||
# the working directory pytest is launched from.
|
||||
@@ -35,36 +32,220 @@ if str(BACKEND_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(BACKEND_ROOT))
|
||||
|
||||
# pydantic-settings auto-loads ``.env`` from the current working directory.
|
||||
# A developer .env may contain operational keys (POSTGRES_PASSWORD, ALLOWED_HOSTS,
|
||||
# ...) that are not declared on the Settings model and would raise
|
||||
# ``extra_forbidden``. Run from a clean temp dir so only env vars are honoured.
|
||||
# Run from a clean temp dir so only env vars are honoured.
|
||||
_TEST_CWD = Path(tempfile.mkdtemp(prefix="pvemanager-tests-"))
|
||||
os.chdir(_TEST_CWD)
|
||||
|
||||
import pytest # noqa: E402
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
# Create an in-memory SQLite engine for testing
|
||||
test_engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
|
||||
|
||||
# Import app.db (this constructs PostgreSQL engine, avoiding SQLite pool parameter TypeError)
|
||||
import app.db
|
||||
|
||||
# Monkeypatch engine and SessionLocal in app.db with SQLite test engine
|
||||
app.db.engine = test_engine
|
||||
app.db.SessionLocal = TestSessionLocal
|
||||
|
||||
# Update environment and settings to reflect SQLite for any other components
|
||||
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
|
||||
from app.config import settings
|
||||
settings.DATABASE_URL = "sqlite:///:memory:"
|
||||
|
||||
# Import all models to register them with SQLAlchemy Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
# Disable lifespan to avoid starting background workers and database migration checks during tests
|
||||
from app.main import app as fastapi_app
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def dummy_lifespan(app):
|
||||
yield
|
||||
|
||||
fastapi_app.router.lifespan_context = dummy_lifespan
|
||||
|
||||
# --- Reusable fixtures --------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_database():
|
||||
"""Recreates the SQLite database tables and seeds default data before each test, dropping them after."""
|
||||
from app.db import Base, init_default_settings
|
||||
from app.models import Role
|
||||
|
||||
Base.metadata.create_all(bind=test_engine)
|
||||
init_default_settings()
|
||||
|
||||
db = TestSessionLocal()
|
||||
try:
|
||||
admin_role = Role(
|
||||
name="admin",
|
||||
display_name="Administrator",
|
||||
permissions={"*": True},
|
||||
is_system=True
|
||||
)
|
||||
user_role = Role(
|
||||
name="user",
|
||||
display_name="User",
|
||||
permissions={"proxmox.view": True, "vms.view": True},
|
||||
is_system=True
|
||||
)
|
||||
db.add_all([admin_role, user_role])
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
yield
|
||||
|
||||
Base.metadata.drop_all(bind=test_engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_session():
|
||||
"""Provides a database session for each test."""
|
||||
session = TestSessionLocal()
|
||||
yield session
|
||||
session.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_session):
|
||||
"""Provides a TestClient with get_db dependency overridden to the test transaction."""
|
||||
from app.db import get_db
|
||||
|
||||
def _get_db_override():
|
||||
yield db_session
|
||||
|
||||
fastapi_app.dependency_overrides[get_db] = _get_db_override
|
||||
from fastapi.testclient import TestClient
|
||||
with TestClient(fastapi_app) as c:
|
||||
yield c
|
||||
fastapi_app.dependency_overrides.pop(get_db, None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def seed_users(db_session):
|
||||
"""Seeds a test administrator and a standard user into the test database."""
|
||||
from app.models import User, Role
|
||||
from app.auth import get_password_hash
|
||||
|
||||
admin_role = db_session.query(Role).filter(Role.name == "admin").first()
|
||||
user_role = db_session.query(Role).filter(Role.name == "user").first()
|
||||
|
||||
admin_user = User(
|
||||
username="testadmin",
|
||||
email="testadmin@example.com",
|
||||
hashed_password=get_password_hash("AdminPass123!"),
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role=admin_role,
|
||||
)
|
||||
test_user = User(
|
||||
username="testuser",
|
||||
email="testuser@example.com",
|
||||
hashed_password=get_password_hash("UserPass123!"),
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
db_session.add_all([admin_user, test_user])
|
||||
db_session.commit() # Commit so the records are visible to all connections/sessions
|
||||
|
||||
return {"admin": admin_user, "user": test_user}
|
||||
|
||||
|
||||
def _get_headers_for_user(db_session, user):
|
||||
"""Helper to generate JWT headers and create an active session in the database."""
|
||||
from app.services.security_service import SecurityService
|
||||
from app.auth import create_access_token
|
||||
|
||||
session, _ = SecurityService.create_session(
|
||||
db_session, user, ip_address="127.0.0.1", user_agent="test-agent"
|
||||
)
|
||||
token = create_access_token(
|
||||
data={"sub": user.username},
|
||||
session_token=session.session_token
|
||||
)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_headers(db_session, seed_users):
|
||||
"""Returns authorization headers for the test admin user."""
|
||||
return _get_headers_for_user(db_session, seed_users["admin"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_headers(db_session, seed_users):
|
||||
"""Returns authorization headers for the test standard user."""
|
||||
return _get_headers_for_user(db_session, seed_users["user"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_proxmox():
|
||||
"""Mocks ProxmoxClient and get_proxmox_resources globally for tests."""
|
||||
from unittest.mock import patch
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance.is_connected.return_value = True
|
||||
mock_client_instance.get_next_vmid.return_value = 101
|
||||
mock_client_instance.get_nodes.return_value = [{"node": "pve1", "uptime": 99999}]
|
||||
mock_client_instance.get_all_resources.return_value = {
|
||||
"vms": [
|
||||
{"vmid": 100, "name": "vm100", "status": "running", "node": "pve1", "template": 0, "type": "qemu"}
|
||||
],
|
||||
"containers": [
|
||||
{"vmid": 200, "name": "ct200", "status": "stopped", "node": "pve1", "template": 0, "type": "lxc"}
|
||||
]
|
||||
}
|
||||
mock_client_instance.get_vm_status.return_value = {"status": "running", "uptime": 12345}
|
||||
mock_client_instance.get_container_status.return_value = {"status": "stopped"}
|
||||
mock_client_instance.get_vm_config.return_value = {"cores": 2, "memory": 2048, "name": "vm100", "node": "pve1"}
|
||||
mock_client_instance.get_container_config.return_value = {"cores": 1, "memory": 1024, "name": "ct200", "node": "pve1"}
|
||||
mock_client_instance.get_vm_interfaces.return_value = [{"name": "net0", "ip": "192.168.1.100"}]
|
||||
mock_client_instance.delete_vm.return_value = "UPID:pve1:00001D4C:00000000:666:qvmdelete:100:testadmin:"
|
||||
mock_client_instance.delete_container.return_value = "UPID:pve1:00001D4C:00000000:666:ctdelete:200:testadmin:"
|
||||
|
||||
with patch("app.proxmox.client.ProxmoxClient", return_value=mock_client_instance), \
|
||||
patch("app.proxmox.ProxmoxClient", return_value=mock_client_instance), \
|
||||
patch("app.api.proxmox._helpers.ProxmoxClient", return_value=mock_client_instance), \
|
||||
patch("app.api.proxmox._helpers._get_proxmox_client", return_value=mock_client_instance), \
|
||||
patch("app.proxmox.get_proxmox_resources") as mock_resources:
|
||||
|
||||
mock_resources.return_value = mock_client_instance.get_all_resources()
|
||||
yield {
|
||||
"client": mock_client_instance,
|
||||
"get_proxmox_resources": mock_resources
|
||||
}
|
||||
|
||||
|
||||
# --- Legacy fixtures for compatibility with existing unit tests ---------------
|
||||
|
||||
@pytest.fixture
|
||||
def fake_role_factory():
|
||||
"""Build a stand-in Role object with a permissions dict."""
|
||||
|
||||
def _make(permissions: dict | None = None, name: str = "tester"):
|
||||
return SimpleNamespace(
|
||||
name=name,
|
||||
permissions=permissions or {},
|
||||
has_permission=lambda perm: bool((permissions or {}).get(perm)),
|
||||
)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_user_factory(fake_role_factory):
|
||||
"""Build a stand-in User object compatible with auth/rbac code paths."""
|
||||
|
||||
def _make(
|
||||
username: str = "alice",
|
||||
is_admin: bool = False,
|
||||
@@ -82,7 +263,6 @@ def fake_user_factory(fake_role_factory):
|
||||
role=role,
|
||||
locked_until=None,
|
||||
)
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user