Files
smart-org/apps/api/tests/conftest.py
T
mjalmoudy94 da5a2a5d1a agy تنها ارائه‌دهندهٔ مدل؛ حذف بخش ارائه‌دهنده‌ها و افزودن زنجیرهٔ انتخاب مدل
- حذف کامل providerهای mock و openai_compatible: دامنه، آداپوترها، رجیستری، کانفیگ، seed و رابطٔ مدیریت
- بخش «ارائه‌دهنده‌ها» از کنسول حذف شد؛ ورود اکانت‌ها فقط از صفحهٔ اکانت‌های agy
- صفحهٔ مدل‌ها بازطراحی شد: ارائه‌دهنده → اکانت → مدل (کاتالوگ لحظه‌ای و زندهٔ همان اکانت از Google) → افورت استدلال؛ بدون هیچ ورود دستی نام مدل
- مدل‌ها ستون reasoning_effort گرفتند؛ آداپتور آن را به thinkingConfig (بودجهٔ ۱۰۲۴ تا ۳۲۷۶۸) ترجمه می‌کند و فقط به مدل‌های استدلالی می‌فرستد
- مهاجرت c2d3e4f5b6a7: افزودن reasoning_effort و حذف ردیف‌های providerهای قدیمی
- تست‌ها: ۲۴۶ تست بک‌اند و ۵۱ تست فرانت سبز؛ ruff و mypy و tsc و eslint تمیز
2026-10-13 10:01:11 +03:30

346 lines
13 KiB
Python

"""Shared test fixtures.
Tests run against an in-memory SQLite database with the real agy-shaped adapter
wiring and a fake model provider, so the suite needs no Postgres, no Redis, no
Google and no accounts. agy is the only model provider the platform talks to
(see ``app/infrastructure/adapters/registry.py``); the fake keeps completions
deterministic and offline.
"""
from __future__ import annotations
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
# Importing the ORM package registers every table on ``Base.metadata``. Bound to
# a private name on purpose: ``app`` is also the FastAPI instance imported below,
# and binding the package to the same name would shadow it.
import app.infrastructure.db.models as _orm_models # noqa: F401
from app.core.config import settings
from app.core.database import Base, get_session
from app.domain.base import new_id
from app.infrastructure.db.models import UserRow
from app.main import app
@pytest.fixture(scope="session", autouse=True)
def _production_shaped_jwt_secret():
"""Sign test tokens with a 32+ byte key.
The development default is ``change-me`` (9 bytes), so PyJWT emits an
``InsecureKeyLengthWarning`` for every token signed or verified — hundreds of
lines per run, which buries genuine warnings. Production refuses a short
secret outright; that guard is covered by ``tests/test_config_guard.py``.
"""
settings.jwt_secret = "test-only-jwt-secret-with-more-than-32-bytes"
@pytest_asyncio.fixture
async def engine():
# StaticPool keeps one connection alive so the in-memory schema survives
# across the sessions a single request opens.
test_engine = create_async_engine(
"sqlite+aiosqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield test_engine
await test_engine.dispose()
@pytest_asyncio.fixture
async def session_factory(engine):
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@pytest_asyncio.fixture
async def redirect_error_log(engine, monkeypatch):
"""Point the error recorder and the workers at the in-memory database.
Neither goes through the ``get_session`` dependency override that ``client``
installs — the recorder deliberately opens its own session, and the workers
are not HTTP requests at all. Without this redirect, any test that triggers a
failure path would write into the developer's real ``agy_web.db``.
Not autouse: only the modules that exercise failure paths need it, and the
``engine`` it depends on builds the whole schema.
"""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
import app.infrastructure.error_log as error_log
from app.workers import base as worker_base
factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
monkeypatch.setattr(error_log, "SessionFactory", factory)
monkeypatch.setattr(worker_base, "SessionFactory", factory)
return factory
@pytest_asyncio.fixture
async def client(engine, session_factory):
async def _override_session():
async with session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
app.dependency_overrides[get_session] = _override_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as test_client:
yield test_client
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Fake model provider — the only sanctioned stand-in for agy in tests
# ---------------------------------------------------------------------------
class _FakeModelProvider:
"""Offline stand-in for the agy adapter.
The canned text must read like model output, not like a debug tag: the
provider/model identity is platform-internal and must never surface in
anything a customer can read (see the e2e smoke test).
"""
async def complete(
self,
*,
model,
messages,
system="",
temperature=0.2,
max_tokens=2048,
credential=None,
credentials=None,
):
import asyncio
import time
from app.application.ports import ModelResponse
started = time.perf_counter()
last_user = next((m.content for m in reversed(messages) if m.role == "user"), "")
prompt_tokens = sum(len(m.content.split()) for m in messages)
# A tiny sleep so latency bookkeeping runs a real code path.
await asyncio.sleep(0)
content = (
"این پاسخ آزمایشی است. "
f"درخواست دریافتی: {last_user[:280] or '—'}"
)
return ModelResponse(
content=content,
model_internal_name=model.internal_name,
input_tokens=prompt_tokens + len(system.split()),
output_tokens=len(content.split()),
latency_ms=int((time.perf_counter() - started) * 1000),
raw={},
)
async def health(self):
from app.application.ports import ModelHealth
return ModelHealth(ok=True, detail="fake agy pool")
@pytest.fixture(autouse=True)
def _fake_model_provider(monkeypatch):
"""Swap the real model adapter out of every request path.
``container`` and the health router bind ``get_model_provider`` at import
time, so both import sites are patched; without this a completion test
would open the developer's real database and reach the real Google API.
"""
from app.application import container as di_container
from app.interfaces.http.routers import health as health_router
provider = _FakeModelProvider()
monkeypatch.setattr(di_container, "get_model_provider", lambda: provider)
monkeypatch.setattr(health_router, "get_model_provider", lambda: provider)
return provider
async def signup(
client: AsyncClient, email: str, password: str = "password123", name: str = ""
) -> dict:
response = await client.post(
"/api/auth/signup", json={"email": email, "password": password, "name": name}
)
assert response.status_code == 201, response.text
return response.json()
def auth_header(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
@pytest_asyncio.fixture
async def platform_owner_token(client: AsyncClient, session_factory) -> str:
"""A signed-up user promoted to platform owner.
The promotion is done directly in the database because there is deliberately
no API that can grant platform-owner status.
"""
# ``example.com`` is a documentation domain and passes email validation.
# Do NOT use ``.local``/``.test``: those are special-use TLDs and newer
# ``email-validator`` releases reject them, which fails the fixture.
email = "platform-owner@example.com"
body = await signup(client, email, name="مالک سامانه")
async with session_factory() as session:
row = (
await session.execute(select(UserRow).where(UserRow.email == email))
).scalar_one()
row.is_platform_owner = True
await session.commit()
return body["access_token"]
#: The system agent templates ``app/seed.py`` installs. Mirrored here so the test
#: suite exercises the same employee-creation path a seeded install uses.
SYSTEM_TEMPLATES: list[tuple[str, str, str, list[str]]] = [
("مدیر سازمان", "manager", "کارها را تقسیم می‌کند.", ["knowledge.search"]),
(
"برنامه‌نویس",
"developer",
"کد می‌نویسد و تست می‌کند.",
["sandbox.run_command", "sandbox.write_file"],
),
]
#: The routing policies ``app/seed.py`` installs.
SYSTEM_ROUTING_POLICIES: list[tuple[str, str, bool, str]] = [
("سریع و ارزان", "cheap", True, "برای کارهای روزمره."),
("استدلال عمیق", "reasoning", False, "برای تحلیل پیچیده."),
]
#: The agy models ``app/seed.py`` installs: (internal_name, display,
#: capabilities, reasoning_effort). agy is the only provider, so this is the
#: whole catalogue.
SYSTEM_MODELS: list[tuple[str, str, list[str], str]] = [
("gemini-3-flash", "Gemini 3 Flash", ["text", "code", "long_context"], ""),
(
"gemini-3-pro-high",
"Gemini 3 Pro",
["text", "reasoning", "code", "vision", "long_context"],
"high",
),
("claude-sonnet-4-5", "Claude Sonnet 4.5", ["text", "code", "tools"], ""),
(
"claude-sonnet-4-5-thinking",
"Claude Sonnet 4.5 (Thinking)",
["text", "code", "reasoning"],
"high",
),
(
"claude-opus-4-5-thinking",
"Claude Opus 4.5 (Thinking)",
["text", "reasoning", "code", "long_context"],
"high",
),
("gpt-oss-120b-medium", "GPT-OSS 120B Medium", ["text", "code"], ""),
]
#: The rate cards ``app/seed.py`` installs: (unit, customer_price, internal_cost).
SYSTEM_RATE_CARDS: list[tuple[str, float, float]] = [
("model_input_tokens", 0.0000004, 0.0000002),
("model_output_tokens", 0.0000016, 0.0000008),
("tool_call", 0.0005, 0.0001),
("premium_employee_run", 0.02, 0.008),
]
#: The sandbox templates ``app/seed.py`` installs: (name, image, description).
SYSTEM_SANDBOX_TEMPLATES: list[tuple[str, str, str]] = [
("python-3.13", "python:3.13-slim", "محیط پایتون برای اجرای تست و اسکریپت."),
("node-22", "node:22-slim", "محیط Node برای build و اجرای پروژه‌های فرانت‌اند."),
]
@pytest_asyncio.fixture
async def seeded(session_factory):
"""Install the platform-level seed rows a fresh install would have.
Agent templates, routing policies, the agy provider with its model catalog,
the rate cards and the sandbox templates are all *platform* data (no
organization owns them), so without this an employee cannot be created, a
run cannot resolve a model to route to, usage cannot be priced and no
sandbox can be started — none of which the real seeded install lacks.
"""
from app.infrastructure.db.models import (
AgentTemplateRow,
AiModelRow,
AiProviderRow,
RateCardRow,
RoutingPolicyRow,
SandboxTemplateRow,
)
async with session_factory() as session:
for name, role_key, description, tools in SYSTEM_TEMPLATES:
session.add(
AgentTemplateRow(
name=name,
role_key=role_key,
description=description,
default_prompt=f"تو {name} هستی. وظیفه‌ات: {description}",
default_tools_json=tools,
is_system=True,
)
)
for name, mode, is_default, description in SYSTEM_ROUTING_POLICIES:
session.add(
RoutingPolicyRow(
name=name, description=description, mode=mode,
is_default=is_default, is_active=True,
)
)
# A run cannot reach a model without an active provider + model, and
# ``pick_for_mode`` returns ``None`` when the catalogue is empty — which
# surfaces as a ``ProviderUnavailableError`` and a *failed* run.
provider_id = new_id()
session.add(
AiProviderRow(
id=provider_id, name="agy", type="agy", base_url="", is_active=True
)
)
for internal, display, capabilities, effort in SYSTEM_MODELS:
session.add(
AiModelRow(
provider_id=provider_id,
internal_name=internal,
display_name_for_admin=display,
capabilities=capabilities,
input_price_per_1m=0.3,
output_price_per_1m=1.2,
reasoning_effort=effort,
is_active=True,
)
)
for unit, customer_price, internal_cost in SYSTEM_RATE_CARDS:
session.add(
RateCardRow(
name=unit.replace("_", " "), unit=unit,
customer_price=customer_price,
internal_cost_estimate=internal_cost, is_active=True,
)
)
for name, image, description in SYSTEM_SANDBOX_TEMPLATES:
session.add(
SandboxTemplateRow(
name=name, provider="mock", image=image, description=description,
default_cpu=1.0, default_memory_mb=1024, default_disk_mb=2048,
is_system=True,
)
)
await session.commit()
return True