"""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