2026-06-29 00:10:10 +02:00
|
|
|
"""Test fixtures: PostgreSQL test DB, Redis, async test client, auth helpers.
|
2026-06-03 23:52:06 +00:00
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
Each test gets a fresh database schema (created from metadata) and a clean Redis.
|
|
|
|
|
Auth helpers talk to the HTTP API (integration tests).
|
2026-06-03 23:52:06 +00:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import uuid
|
2026-06-03 23:52:06 +00:00
|
|
|
from collections.abc import AsyncGenerator
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
import pytest
|
2026-06-03 23:52:06 +00:00
|
|
|
import pytest_asyncio
|
2026-06-29 00:10:10 +02:00
|
|
|
import redis.asyncio as aioredis
|
2026-06-03 23:52:06 +00:00
|
|
|
from httpx import ASGITransport, AsyncClient
|
2026-06-29 00:10:10 +02:00
|
|
|
from sqlalchemy import text
|
2026-06-03 23:52:06 +00:00
|
|
|
from sqlalchemy.ext.asyncio import (
|
|
|
|
|
AsyncEngine,
|
|
|
|
|
AsyncSession,
|
|
|
|
|
async_sessionmaker,
|
|
|
|
|
create_async_engine,
|
|
|
|
|
)
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
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
|
2026-06-29 00:44:34 +02:00
|
|
|
from app.models.contact import Contact, CompanyContact
|
2026-06-29 01:18:46 +02:00
|
|
|
from app.models.plugin import Plugin, PluginMigration
|
2026-06-29 02:44:13 +02:00
|
|
|
from app.models.ai_conversation import AIConversation, AIMessage
|
|
|
|
|
from app.models.workflow import Workflow, WorkflowInstance, WorkflowStepHistory
|
2026-06-03 23:52:06 +00:00
|
|
|
from app.main import create_app
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
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",
|
2026-06-03 23:52:06 +00:00
|
|
|
echo=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
@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
|
2026-06-29 02:44:13 +02:00
|
|
|
conn.execute(text("TRUNCATE TABLE workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;"))
|
2026-06-29 00:10:10 +02:00
|
|
|
conn.commit()
|
|
|
|
|
sync_eng.dispose()
|
|
|
|
|
yield
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
2026-06-29 00:10:10 +02:00
|
|
|
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()
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
@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()
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
@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)
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
2026-06-29 00:10:10 +02:00
|
|
|
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()
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
2026-06-29 00:10:10 +02:00
|
|
|
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()
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
2026-06-29 00:10:10 +02:00
|
|
|
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
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
# ─── Seed Data Helpers ───
|
2026-06-03 23:52:06 +00:00
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
ORIGIN_HEADER = {"Origin": "http://localhost:5173"}
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
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={},
|
2026-06-03 23:52:06 +00:00
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
# 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={},
|
2026-06-03 23:52:06 +00:00
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
# 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={},
|
2026-06-03 23:52:06 +00:00
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
# 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={},
|
2026-06-10 21:24:24 +00:00
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
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"},
|
2026-06-10 21:24:24 +00:00
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
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,
|
2026-06-10 21:24:24 +00:00
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
# 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,
|
2026-06-10 21:24:24 +00:00
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
db.add_all([company_a, company_b])
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
await db.commit()
|
2026-06-03 23:52:06 +00:00
|
|
|
|
|
|
|
|
return {
|
2026-06-29 00:10:10 +02:00
|
|
|
"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,
|
2026-06-03 23:52:06 +00:00
|
|
|
}
|
2026-06-29 00:10:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|