نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
"""Shared test fixtures.
|
||||
|
||||
Tests run against an in-memory SQLite database and the ``mock`` adapters, so the
|
||||
suite needs no Postgres, no Redis and no credentials.
|
||||
"""
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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 mock models ``app/seed.py`` installs: (internal_name, display, capabilities).
|
||||
SYSTEM_MODELS: list[tuple[str, str, list[str]]] = [
|
||||
("mock-fast", "Mock Fast", ["text"]),
|
||||
("mock-reasoning", "Mock Reasoning", ["text", "reasoning", "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 mock provider/models, 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="mock", type="mock", base_url="", is_active=True)
|
||||
)
|
||||
for internal, display, capabilities 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,
|
||||
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
|
||||
Reference in New Issue
Block a user