Files
smart-org/apps/api/tests/test_credit_chat_gate.py
T
mjalmoudy94 d532929189 feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console
- Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages
- Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review
- Pause/resume/cancel/answer actions in engine API and UI
- Employee cap (7 per org default), review pipeline guard (active reviewer required)
- Scheduler: wide window prevents org starvation; archived employees excluded from cap
- Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox
- Order route fix: manager built with pipeline states + employee profiles
- Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy
- Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed)
- api-engine.ts typed client; api-credit.ts client
- Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables
- 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green

Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
2026-10-14 23:19:05 +03:30

172 lines
6.8 KiB
Python

"""The manager chat does not call a model for an organization with no credit.
What these tests defend:
* a zero-credit organization gets the note as the manager's reply: the model is
never called and the tags in the order are not executed (prepaid, decision D-B)
* a missing wallet is no credit either
* once the owner credits the organization, the same chat reaches the model
* a manager built without a wallet (the direct construction the other manager
tests use) keeps its old behaviour
The organization fixture and the manager's model profile come from
``tests.test_engine_manager``.
"""
from __future__ import annotations
from app.application.container_org import build_memory
from app.application.engine.manager import CHAT_NO_CREDIT_MESSAGE, ManagerTurns
from app.domain.workforce.models import MessageRole, RoleKey
from app.infrastructure.db.models import CreditWalletRow, ThreadRow
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
MessageRepository,
TaskRepository,
ThreadRepository,
)
from tests.billing_helpers import fund_org
from tests.engine_fakes import FakeGrants, ScriptedModel, ScriptedReply
from tests.test_engine_manager import _with_model_profile
from tests.test_engine_manager import world as manager_world
#: The engine manager tests' organization fixture. Binding it under its own name
#: registers it for this module's tests (a bare import would be flagged as unused).
world = manager_world
_ORDER = '[[CREATE_TASK title="ورود" kind="feature"]]ساخت فرم ورود[[/CREATE_TASK]]'
_PLAIN_REPLY = "آماده‌ام."
async def _thread_with_manager(world, session) -> str:
"""A manager with a strong model profile, and a thread to talk in."""
manager = await world.employee(session, "مدیر", RoleKey.MANAGER)
await _with_model_profile(world, session, manager.id, world.strong_id)
thread = await ThreadRepository(session).create(
ThreadRow(organization_id=world.organization_id, title="گفتگو")
)
await session.commit()
return thread.id
async def _manager(session, *, scripted, grants, wired: bool) -> ManagerTurns:
"""The engine manager on real repositories; ``wired`` adds the prepaid gate."""
return ManagerTurns(
provider=scripted,
employees=EmployeeRepository(session),
messages=MessageRepository(session),
tasks=TaskRepository(session),
threads=ThreadRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=grants,
memory=build_memory(session),
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
runner=None,
wallets=CreditWalletRepository(session) if wired else None,
)
async def test_a_zero_credit_org_gets_the_note_and_no_model_call(world, session_factory):
async with session_factory() as session:
session.add(
CreditWalletRow(organization_id=world.organization_id, balance=0.0, currency="USD")
)
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_ORDER)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
user_message, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="یک فرم ورود بساز.",
user_id=world.user_id,
)
await session.commit()
tasks = await TaskRepository(session).list_for_org(world.organization_id)
assert user_message.content == "یک فرم ورود بساز."
assert assistant.role is MessageRole.ASSISTANT
assert assistant.content == CHAT_NO_CREDIT_MESSAGE
assert scripted.calls == 0
# The order's CREATE_TASK tag was not executed.
assert tasks == []
async def test_a_missing_wallet_counts_as_no_credit(world, session_factory):
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == CHAT_NO_CREDIT_MESSAGE
assert scripted.calls == 0
async def test_after_the_owner_credits_the_org_the_model_answers(world, session_factory):
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
await fund_org(session_factory, world.organization_id, 50.0)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=True)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == _PLAIN_REPLY
assert scripted.calls == 1
async def test_a_manager_built_without_a_wallet_keeps_the_old_behaviour(world, session_factory):
"""No ``wallets`` means no gate: the direct constructions in other tests rely on it."""
async with session_factory() as session:
thread_id = await _thread_with_manager(world, session)
scripted = ScriptedModel([ScriptedReply(content=_PLAIN_REPLY)])
grants = FakeGrants({world.strong_id: "strong"})
async with session_factory() as session:
manager = await _manager(session, scripted=scripted, grants=grants, wired=False)
_, assistant = await manager.chat(
organization_id=world.organization_id,
thread_id=thread_id,
content="سلام",
user_id=world.user_id,
)
await session.commit()
assert assistant.content == _PLAIN_REPLY
assert scripted.calls == 1