- 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
256 lines
11 KiB
Python
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
|