Files
smart-org/apps/api/tests/test_engine_api_team.py
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

256 lines
11 KiB
Python

"""The engine's team API: the team with tiers, hire by role and tier, and fire.
What these tests defend:
* ``GET /team`` shows each member's neutral tier, ``unknown`` while no granted model backs it;
the tiers the organization has; the owner's cap; and not the archived members
* hire takes a role and a tier, never a model. A tier with no granted model is a 409, and the
cap is the owner's policy: a hire at the cap is a 409
* firing archives the member; the last active manager cannot be fired (409)
* a member without ``employees.manage`` gets 403 on hire and fire; a foreign member gets 404
* no response names a model, a provider or an ``ai_model_id``
"""
from __future__ import annotations
from sqlalchemy import select
from app.application.container_org import build_model_grants
from app.domain.audit.models import AuditAction
from app.domain.identity.models import Role
from app.infrastructure.db.models import AiModelRow, AiProviderRow, AuditEventRow
from tests.conftest import auth_header
from tests.test_engine_api import _base, _join, _make_org
REFUSED_NO_GRANT = "مدلی از این سطح"
REFUSED_CAP = "سقف تعداد کارمندان"
REFUSED_LAST_MANAGER = "آخرین مدیر"
async def _strong_model_id(session_factory) -> str:
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
return next(m.id for m in models if "reasoning" in (m.capabilities or []))
async def _grant_strong(client, owner_token: str, organization_id: str, session_factory) -> str:
"""Give the organization the strong model only, so its tiers are exactly ``strong``."""
model_id = await _strong_model_id(session_factory)
response = await client.put(
f"/api/admin/model-grants/{organization_id}/models/{model_id}",
json={},
headers=auth_header(owner_token),
)
assert response.status_code == 200, response.text
return model_id
async def _set_cap(client, owner_token: str, organization_id: str, cap: int) -> None:
response = await client.put(
f"/api/admin/org-policies/{organization_id}",
json={"max_employees": cap},
headers=auth_header(owner_token),
)
assert response.status_code == 200, response.text
async def _team(client, token: str, organization_id: str) -> dict:
response = await client.get(f"{_base(organization_id)}/team", headers=auth_header(token))
assert response.status_code == 200, response.text
return response.json()
async def _hire(client, token: str, organization_id: str, role: str, tier: str, name: str = ""):
return await client.post(
f"{_base(organization_id)}/team/hire",
json={"role": role, "tier": tier, "name": name},
headers=auth_header(token),
)
async def _forbidden_values(session_factory, model_id: str) -> set[str]:
"""Every model name, admin label, provider name and model id the seed knows, plus this one."""
async with session_factory() as session:
models = list((await session.execute(select(AiModelRow))).scalars().all())
providers = list((await session.execute(select(AiProviderRow))).scalars().all())
values = {model_id}
for model in models:
values.update({model.internal_name, model.display_name_for_admin, model.id})
values.update(provider.name for provider in providers)
# Names shorter than three characters would match ordinary text; the seed has none.
return {value for value in values if len(value) >= 3}
async def test_the_team_view_shows_neutral_tiers_and_the_owners_cap(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-view")
model_id = await _grant_strong(client, platform_owner_token, org.id, session_factory)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
body = await _team(client, org.owner_token, org.id)
assert body["max_employees"] == cap
assert body["tiers"] == [{"tier": "strong", "label": "قوی"}]
assert body["count"] == len(body["employees"])
roles = {member["role_key"]: member for member in body["employees"]}
# The team was bootstrapped before the grant, so nobody runs on a granted model yet.
assert roles["manager"]["tier"] == "unknown"
assert roles["manager"]["status"] == "paused"
for value in await _forbidden_values(session_factory, model_id):
assert value not in str(body), f"team names {value!r}"
async def test_hire_takes_a_role_and_a_tier_and_returns_the_member(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-hire")
model_id = await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
before = (await _team(client, org.owner_token, org.id))["count"]
hired = await _hire(
client, org.owner_token, org.id, "developer", "strong", "برنامه‌نویس دوم"
)
assert hired.status_code == 201, hired.text
body = hired.json()
assert body["tier"] == "strong"
assert body["role_key"] == "developer"
assert body["name"] == "برنامه‌نویس دوم"
assert body["status"] == "active"
for value in await _forbidden_values(session_factory, model_id):
assert value not in hired.text, f"hire names {value!r}"
team = await _team(client, org.owner_token, org.id)
assert team["count"] == before + 1
assert body["id"] in {member["id"] for member in team["employees"]}
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.target_id == body["id"])
)
)
.scalars()
.all()
)
assert [e.action for e in events] == [str(AuditAction.EMPLOYEE_CREATE)]
assert events[0].metadata_json == {"role_key": "developer", "tier": "strong"}
async def test_a_tier_with_no_granted_model_is_a_conflict(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-tier")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
for tier in ("fast", "balanced", "ultra"):
response = await _hire(client, org.owner_token, org.id, "tester", tier)
assert response.status_code == 409, f"{tier}: {response.text}"
assert REFUSED_NO_GRANT in response.json()["error"]["message"]
async def test_the_cap_is_the_owners_policy_and_a_hire_stops_at_it(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-cap")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
count = (await _team(client, org.owner_token, org.id))["count"]
await _set_cap(client, platform_owner_token, org.id, count + 1)
first = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert first.status_code == 201, first.text
second = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert second.status_code == 409, second.text
assert REFUSED_CAP in second.json()["error"]["message"]
team = await _team(client, org.owner_token, org.id)
async with session_factory() as session:
cap = (await build_model_grants(session).get_org_policy(org.id)).max_employees
assert team["max_employees"] == cap == count + 1
assert team["count"] == count + 1
async def test_the_last_manager_cannot_be_fired_and_a_fired_member_is_archived(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-fire")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
before = await _team(client, org.owner_token, org.id)
manager = next(m for m in before["employees"] if m["role_key"] == "manager")
headers = auth_header(org.owner_token)
refused = await client.post(f"{_base(org.id)}/team/{manager['id']}/fire", headers=headers)
assert refused.status_code == 409, refused.text
assert REFUSED_LAST_MANAGER in refused.json()["error"]["message"]
hired = await _hire(client, org.owner_token, org.id, "developer", "strong")
assert hired.status_code == 201, hired.text
fired = await client.post(
f"{_base(org.id)}/team/{hired.json()['id']}/fire", headers=headers
)
assert fired.status_code == 200, fired.text
assert fired.json()["status"] == "archived"
after = await _team(client, org.owner_token, org.id)
assert hired.json()["id"] not in {m["id"] for m in after["employees"]}
assert after["count"] == before["count"]
async with session_factory() as session:
events = list(
(
await session.execute(
select(AuditEventRow).where(AuditEventRow.target_id == hired.json()["id"])
)
)
.scalars()
.all()
)
actions = [(e.action, e.metadata_json) for e in events]
assert (str(AuditAction.EMPLOYEE_UPDATE), {"action": "fire"}) in actions
async def test_a_member_without_the_capability_is_refused_on_hire_and_fire(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-member")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
member = await _join(
client, session_factory, org.id, Role.MEMBER, "team-member-user@example.com"
)
team = await _team(client, org.owner_token, org.id)
target = team["employees"][0]["id"]
hired = await _hire(client, member, org.id, "developer", "strong")
assert hired.status_code == 403, hired.text
fired = await client.post(f"{_base(org.id)}/team/{target}/fire", headers=auth_header(member))
assert fired.status_code == 403, fired.text
# The member still reads the team.
readable = await client.get(f"{_base(org.id)}/team", headers=auth_header(member))
assert readable.status_code == 200, readable.text
async def test_another_organization_gets_not_found_on_the_team(
client, session_factory, seeded, platform_owner_token
):
org = await _make_org(client, "team-a")
await _grant_strong(client, platform_owner_token, org.id, session_factory)
await _set_cap(client, platform_owner_token, org.id, 12)
team = await _team(client, org.owner_token, org.id)
target = team["employees"][0]["id"]
other = await _make_org(client, "team-b")
headers = auth_header(other.owner_token)
assert (await client.get(f"{_base(org.id)}/team", headers=headers)).status_code == 404
hired = await client.post(
f"{_base(org.id)}/team/hire",
json={"role": "developer", "tier": "strong"},
headers=headers,
)
assert hired.status_code == 404, hired.text
fired = await client.post(f"{_base(org.id)}/team/{target}/fire", headers=headers)
assert fired.status_code == 404, fired.text