Files
crm-system/tests/conftest.py
T
leocrm-bot 7a5a48fb4c T03: plugin system framework + lifecycle + migrations + event bus + DI
- Plugin registry with discover/install/activate/deactivate/uninstall lifecycle
- PluginManifest Pydantic v2 schema (name, version, dependencies, routes, events, migrations)
- BasePlugin abstract class with lifecycle hooks (on_install/activate/deactivate/uninstall)
- Migration runner with tenant_id validator (rejects tables without tenant_id)
- Event bus integration: register/unregister listeners on activate/deactivate
- Service container DI: plugins receive db, cache, event_bus, storage, notifications
- Idempotent operations (activate active=200, deactivate inactive=200)
- UI registry for frontend component registration
- 47 new tests (14 ACs + 33 unit tests), 103 total tests pass
- Migration 0003: plugins + plugin_migrations tables
- Coverage: 85.92% for plugin modules
2026-06-29 01:18:46 +02:00

272 lines
8.5 KiB
Python

"""Test fixtures: PostgreSQL test DB, Redis, async test client, auth helpers.
Each test gets a fresh database schema (created from metadata) and a clean Redis.
Auth helpers talk to the HTTP API (integration tests).
"""
from __future__ import annotations
import asyncio
import json
import uuid
from collections.abc import AsyncGenerator
from typing import Any
import pytest
import pytest_asyncio
import redis.asyncio as aioredis
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.config import get_settings
from app.core.db import Base, reset_engine_for_testing, close_engine
from app.core.auth import hash_password
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
from app.models.role import Role
from app.models.company import Company
from app.models.contact import Contact, CompanyContact
from app.models.plugin import Plugin, PluginMigration
from app.main import create_app
TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
def _get_sync_engine():
"""Create a sync engine for DDL operations (drop/create schema).
Uses postgres superuser because leocrm user doesn't own the public schema.
"""
from sqlalchemy import create_engine
return create_engine(
"postgresql+psycopg2://postgres@localhost:5432/leocrm_test",
echo=False,
)
@pytest.fixture(scope="session", autouse=True)
def db_setup():
"""Drop and recreate all tables once per test session."""
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
# Drop all tables and types
conn.execute(text("DROP SCHEMA public CASCADE;"))
conn.execute(text("CREATE SCHEMA public;"))
conn.execute(text("GRANT ALL ON SCHEMA public TO leocrm;"))
conn.commit()
sync_eng.dispose()
# Create tables using async engine
async def _create():
eng = create_async_engine(TEST_DB_URL, echo=False)
async with eng.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await eng.dispose()
asyncio.get_event_loop().run_until_complete(_create())
yield
# Cleanup after session
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
conn.execute(text("DROP SCHEMA public CASCADE;"))
conn.execute(text("CREATE SCHEMA public;"))
conn.execute(text("GRANT ALL ON SCHEMA public TO leocrm;"))
conn.commit()
sync_eng.dispose()
@pytest.fixture(autouse=True)
def clean_tables(db_setup):
"""Clean all table data before each test (preserve schema)."""
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
# TRUNCATE all tables with CASCADE — fast and reliable isolation
conn.execute(text("TRUNCATE TABLE plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
conn.commit()
sync_eng.dispose()
yield
@pytest_asyncio.fixture
async def redis_client() -> AsyncGenerator[aioredis.Redis, None]:
"""Redis client for tests — flushes DB before and after."""
r = aioredis.from_url("redis://localhost:6379/0", decode_responses=True)
await r.flushdb()
yield r
await r.flushdb()
await r.aclose()
@pytest_asyncio.fixture
async def engine() -> AsyncGenerator[AsyncEngine, None]:
"""Async engine for the test database."""
eng = create_async_engine(TEST_DB_URL, echo=False)
yield eng
await eng.dispose()
@pytest_asyncio.fixture
async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
"""Session factory bound to the test engine."""
return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
@pytest_asyncio.fixture
async def db_session(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]:
"""Database session for direct DB operations in tests."""
async with session_factory() as session:
yield session
await session.rollback()
@pytest_asyncio.fixture
async def app(engine: AsyncEngine, redis_client: aioredis.Redis):
"""FastAPI app with test engine injected."""
reset_engine_for_testing(engine)
app = create_app()
yield app
await close_engine()
@pytest_asyncio.fixture
async def client(app) -> AsyncGenerator[AsyncClient, None]:
"""HTTP async test client."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
# ─── Seed Data Helpers ───
ORIGIN_HEADER = {"Origin": "http://localhost:5173"}
async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]:
"""Seed two tenants with admin, editor, viewer users.
Returns dict with all created entity IDs.
"""
tenant_a = Tenant(name="Tenant A", slug="tenant-a")
tenant_b = Tenant(name="Tenant B", slug="tenant-b")
db.add_all([tenant_a, tenant_b])
await db.flush()
# Admin in tenant A
admin_a = User(
tenant_id=tenant_a.id,
email="admin@tenanta.com",
name="Admin A",
password_hash=hash_password("TestPass123!"),
role="admin",
is_active=True,
preferences={},
)
# Viewer in tenant A
viewer_a = User(
tenant_id=tenant_a.id,
email="viewer@tenanta.com",
name="Viewer A",
password_hash=hash_password("TestPass123!"),
role="viewer",
is_active=True,
preferences={},
)
# Editor in tenant A
editor_a = User(
tenant_id=tenant_a.id,
email="editor@tenanta.com",
name="Editor A",
password_hash=hash_password("TestPass123!"),
role="editor",
is_active=True,
preferences={},
)
# Admin in tenant B
admin_b = User(
tenant_id=tenant_b.id,
email="admin@tenantb.com",
name="Admin B",
password_hash=hash_password("TestPass123!"),
role="admin",
is_active=True,
preferences={},
)
db.add_all([admin_a, viewer_a, editor_a, admin_b])
await db.flush()
# User-tenant memberships
ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True)
ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True)
ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True)
ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True)
# Admin A is also member of tenant B (for switch-tenant test)
ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False)
db.add_all([ut1, ut2, ut3, ut4, ut5])
await db.flush()
# Create a custom role with field permissions in tenant A
custom_role = Role(
tenant_id=tenant_a.id,
name="sales_rep",
permissions={"companies": {"read": True, "create": True, "update": True, "delete": False}},
field_permissions={"annual_revenue": "hidden"},
)
db.add(custom_role)
await db.flush()
# Create a company in tenant A
company_a = Company(
tenant_id=tenant_a.id,
name="Company Alpha",
industry="IT",
created_by=admin_a.id,
updated_by=admin_a.id,
)
# Create a company in tenant B
company_b = Company(
tenant_id=tenant_b.id,
name="Company Beta",
industry="Finance",
created_by=admin_b.id,
updated_by=admin_b.id,
)
db.add_all([company_a, company_b])
await db.flush()
await db.commit()
return {
"tenant_a": tenant_a,
"tenant_b": tenant_b,
"admin_a": admin_a,
"viewer_a": viewer_a,
"editor_a": editor_a,
"admin_b": admin_b,
"company_a": company_a,
"company_b": company_b,
"custom_role": custom_role,
}
async def login_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> dict[str, str]:
"""Login via HTTP API and return cookies dict."""
resp = await client.post(
"/api/v1/auth/login",
json={"email": email, "password": password},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}"
return dict(resp.cookies)
async def get_auth_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> AsyncClient:
"""Return a client that's logged in."""
await login_client(client, email, password)
return client