322 lines
10 KiB
Python
322 lines
10 KiB
Python
"""Test fixtures: in-memory SQLite DB, async test client, auth helpers.
|
|
|
|
Design:
|
|
- One AsyncEngine + session_factory per test (function-scoped) so each test
|
|
has a fresh, isolated DB.
|
|
- The FastAPI app's get_db dependency is overridden to use the same engine.
|
|
- register/headers/login helpers talk to the HTTP API (integration tests).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import AsyncGenerator
|
|
from typing import Any
|
|
|
|
import pytest_asyncio
|
|
from httpx import ASGITransport, AsyncClient
|
|
from sqlalchemy.ext.asyncio import (
|
|
AsyncEngine,
|
|
AsyncSession,
|
|
async_sessionmaker,
|
|
create_async_engine,
|
|
)
|
|
|
|
from app.core.db import Base, get_db
|
|
from app.main import create_app
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def engine() -> AsyncGenerator[AsyncEngine, None]:
|
|
"""Per-test in-memory SQLite engine with schema created from metadata."""
|
|
eng = create_async_engine(
|
|
"sqlite+aiosqlite:///:memory:",
|
|
echo=False,
|
|
future=True,
|
|
)
|
|
async with eng.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
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 client(
|
|
session_factory: async_sessionmaker[AsyncSession],
|
|
) -> AsyncGenerator[AsyncClient, None]:
|
|
"""httpx.AsyncClient wired to a fresh FastAPI app with the test DB injected."""
|
|
app = create_app()
|
|
|
|
async def _override_get_db() -> AsyncGenerator[AsyncSession, None]:
|
|
async with session_factory() as session:
|
|
try:
|
|
yield session
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
app.dependency_overrides[get_db] = _override_get_db
|
|
|
|
transport = ASGITransport(app=app)
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
|
|
# === Convenience: register via API (covers bootstrap, user, token, headers) ===
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def registered_user(
|
|
client: AsyncClient,
|
|
) -> dict[str, Any]:
|
|
"""Register a bootstrap user via the API.
|
|
|
|
Returns a dict with user, token, headers, email, password, name.
|
|
Use this fixture (or the dependent `auth_headers`) for all auth-required tests.
|
|
"""
|
|
payload = {
|
|
"email": "admin@test.com",
|
|
"password": "TestPass123!",
|
|
"name": "Test Admin",
|
|
}
|
|
resp = await client.post("/api/v1/auth/register", json=payload)
|
|
assert resp.status_code == 201, f"register failed: {resp.status_code} {resp.text}"
|
|
data = resp.json()
|
|
token = data["access_token"]
|
|
return {
|
|
"user": data["user"],
|
|
"token": token,
|
|
"headers": {"Authorization": f"Bearer {token}"},
|
|
"email": payload["email"],
|
|
"password": payload["password"],
|
|
"name": payload["name"],
|
|
}
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def auth_headers(registered_user: dict[str, Any]) -> dict[str, str]:
|
|
"""Authorization headers for the bootstrap user."""
|
|
return registered_user["headers"]
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def second_user(
|
|
client: AsyncClient,
|
|
registered_user: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Register a second user (admin-only flow) and return its auth info."""
|
|
# Use admin headers to create another user
|
|
admin_headers = registered_user["headers"]
|
|
payload = {
|
|
"email": "rep@test.com",
|
|
"password": "RepPass123!",
|
|
"name": "Test Rep",
|
|
"role": "sales_rep",
|
|
}
|
|
resp = await client.post("/api/v1/users/", json=payload, headers=admin_headers)
|
|
assert resp.status_code == 201, f"create user failed: {resp.status_code} {resp.text}"
|
|
new_user = resp.json()
|
|
|
|
# Log in as the new user to get a token
|
|
login_resp = await client.post(
|
|
"/api/v1/auth/login",
|
|
data={"username": payload["email"], "password": payload["password"]},
|
|
)
|
|
assert login_resp.status_code == 200, (
|
|
f"login failed: {login_resp.status_code} {login_resp.text}"
|
|
)
|
|
token = login_resp.json()["access_token"]
|
|
|
|
return {
|
|
"user": new_user,
|
|
"token": token,
|
|
"headers": {"Authorization": f"Bearer {token}"},
|
|
"email": payload["email"],
|
|
"password": payload["password"],
|
|
"name": payload["name"],
|
|
}
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def seed_data(
|
|
client: AsyncClient,
|
|
registered_user: dict[str, Any],
|
|
session_factory: async_sessionmaker[AsyncSession],
|
|
) -> dict[str, Any]:
|
|
"""Seed a representative data set for business-logic tests.
|
|
|
|
Creates (via the API to exercise FK constraints end-to-end):
|
|
- 2 accounts owned by the bootstrap admin user
|
|
- 3 contacts (2 attached to accounts, 1 standalone)
|
|
- 5 deals across various stages
|
|
- 10 activities (some overdue, some completed)
|
|
- 3 tags (VIP, Strategic, Enterprise)
|
|
- 4 notes (mixed parents)
|
|
|
|
Returns a dict with all IDs + a ref to the auth headers for convenience.
|
|
"""
|
|
headers = registered_user["headers"]
|
|
|
|
# 2 accounts
|
|
acc1 = await client.post(
|
|
"/api/v1/accounts/",
|
|
json={
|
|
"name": "Acme Corp",
|
|
"industry": "sme",
|
|
"size": "sme",
|
|
"website": "https://acme.test",
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert acc1.status_code == 201, f"seed acc1: {acc1.status_code} {acc1.text}"
|
|
acc1_id = acc1.json()["id"]
|
|
|
|
acc2 = await client.post(
|
|
"/api/v1/accounts/",
|
|
json={"name": "Globex GmbH", "industry": "enterprise", "size": "enterprise"},
|
|
headers=headers,
|
|
)
|
|
assert acc2.status_code == 201, f"seed acc2: {acc2.status_code} {acc2.text}"
|
|
acc2_id = acc2.json()["id"]
|
|
|
|
# 3 contacts (2 with account, 1 standalone)
|
|
c1 = await client.post(
|
|
"/api/v1/contacts/",
|
|
json={
|
|
"first_name": "Anna",
|
|
"last_name": "Schmidt",
|
|
"email": "anna@acme.example",
|
|
"account_id": acc1_id,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert c1.status_code == 201, c1.text
|
|
c1_id = c1.json()["id"]
|
|
|
|
c2 = await client.post(
|
|
"/api/v1/contacts/",
|
|
json={
|
|
"first_name": "Bob",
|
|
"last_name": "Mueller",
|
|
"email": "bob@globex.example",
|
|
"account_id": acc2_id,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert c2.status_code == 201, c2.text
|
|
c2_id = c2.json()["id"]
|
|
|
|
c3 = await client.post(
|
|
"/api/v1/contacts/",
|
|
json={"first_name": "Clara", "last_name": "Weber", "email": "clara@x.example"},
|
|
headers=headers,
|
|
)
|
|
assert c3.status_code == 201, c3.text
|
|
c3_id = c3.json()["id"]
|
|
|
|
# 5 deals (different stages)
|
|
deal_ids: list[int] = []
|
|
for i, (title, stage) in enumerate(
|
|
[
|
|
("Deal A", "lead"),
|
|
("Deal B", "qualified"),
|
|
("Deal C", "proposal"),
|
|
("Deal D", "negotiation"),
|
|
("Deal E", "won"),
|
|
]
|
|
):
|
|
d = await client.post(
|
|
"/api/v1/deals/",
|
|
json={
|
|
"title": title,
|
|
"value": str(1000 * (i + 1)),
|
|
"stage": stage,
|
|
"account_id": acc1_id if i % 2 == 0 else acc2_id,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert d.status_code == 201, d.text
|
|
deal_ids.append(d.json()["id"])
|
|
|
|
# 10 activities (4 overdue, 4 future, 2 completed)
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
past = (datetime.now(UTC) - timedelta(days=2)).isoformat()
|
|
future = (datetime.now(UTC) + timedelta(days=2)).isoformat()
|
|
future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat()
|
|
activity_ids: list[int] = []
|
|
for i in range(10):
|
|
if i < 4:
|
|
due = past
|
|
body_data: dict[str, object] = {
|
|
"type": "task",
|
|
"subject": f"Overdue task {i}",
|
|
"due_date": due,
|
|
"deal_id": deal_ids[i % 5],
|
|
}
|
|
elif i < 8:
|
|
due = future if i % 2 == 0 else future2
|
|
body_data = {
|
|
"type": "call",
|
|
"subject": f"Upcoming call {i}",
|
|
"due_date": due,
|
|
"account_id": acc1_id,
|
|
}
|
|
else:
|
|
body_data = {"type": "meeting", "subject": f"Done meeting {i}", "account_id": acc1_id}
|
|
a = await client.post("/api/v1/activities/", json=body_data, headers=headers)
|
|
assert a.status_code == 201, a.text
|
|
activity_ids.append(a.json()["id"])
|
|
|
|
# 3 tags
|
|
tag_ids: list[int] = []
|
|
for name in ["VIP", "Strategic", "Enterprise"]:
|
|
t = await client.post(
|
|
"/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers
|
|
)
|
|
assert t.status_code == 201, t.text
|
|
tag_ids.append(t.json()["id"])
|
|
|
|
# 4 notes (mixed parents: 2 account, 1 contact, 1 deal)
|
|
n1 = await client.post(
|
|
"/api/v1/notes/",
|
|
json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id},
|
|
headers=headers,
|
|
)
|
|
assert n1.status_code == 201, n1.text
|
|
n2 = await client.post(
|
|
"/api/v1/notes/",
|
|
json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id},
|
|
headers=headers,
|
|
)
|
|
assert n2.status_code == 201, n2.text
|
|
n3 = await client.post(
|
|
"/api/v1/notes/",
|
|
json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id},
|
|
headers=headers,
|
|
)
|
|
assert n3.status_code == 201, n3.text
|
|
n4 = await client.post(
|
|
"/api/v1/notes/",
|
|
json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]},
|
|
headers=headers,
|
|
)
|
|
assert n4.status_code == 201, n4.text
|
|
|
|
return {
|
|
"account_ids": [acc1_id, acc2_id],
|
|
"contact_ids": [c1_id, c2_id, c3_id],
|
|
"deal_ids": deal_ids,
|
|
"activity_ids": activity_ids,
|
|
"tag_ids": tag_ids,
|
|
"note_ids": [n1.json()["id"], n2.json()["id"], n3.json()["id"], n4.json()["id"]],
|
|
"headers": headers,
|
|
}
|