Files
smart-org/apps/api/tests/test_seed.py
T
mjalmoudy94 8eb631aedf ساختار مالکیت پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک، سازمان‌های مدیریت‌شده
- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت
  d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین
  می‌کند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد.
- ثبت‌نام عمومی حذف شد (POST /auth/signup). حساب‌ها فقط با POST /admin/users
  (فقط مالک، با رمز موقت یک‌بارمصرف) یا با پذیرش لینک دعوت ساخته می‌شوند.
  در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوت‌شده رمز تعیین می‌کند؛ اگر
  وجود داشته باشد لینک هرگز جای reset رمز را نمی‌گیرد.
- ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو می‌شود.
- تنزل یا حذف مدیر، سازمان‌هایش را به مالک سامانه منتقل می‌کند (مالکیت + عضویت،
  با audit). حساب مالک از API تغییرناپذیر است؛ جابه‌جایی صندلی فقط با
  POST /admin/transfer-ownership و در یک تراکنش انجام می‌شود.
- افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی.
- فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup،
  گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ.
- PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان
  رفتار کند (پیش‌تر حذف کاربر، عضویت‌هایش را یتیم می‌گذاشت).
- تست: ۳۰۰ تست بک‌اند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست
  فرانت‌اند؛ ruff، mypy، tsc، eslint و build تمیز.
2026-10-13 21:58:41 +03:30

252 lines
8.7 KiB
Python

"""§17 — the seed produces a stack a developer can actually log into.
This file exists because of two real bugs, both found by trying to *use* a
seeded install rather than by reading the code:
1. The seed created ``demo@agy.local`` by writing straight to the database,
bypassing request validation — while the login endpoint rejects ``.local`` as
a special-use TLD. The account existed and was unreachable.
2. Even the account that *could* log in belonged to no organization, so every
screen rendered empty and the demo looked broken.
Neither was caught by the rest of the suite, because every other test signs up a
fresh user at ``@example.com`` and creates its own organization. The seed was
simply never exercised end to end. These tests close that hole.
"""
from __future__ import annotations
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import func, select
from tests.conftest import auth_header
@pytest_asyncio.fixture
async def seeded_install(monkeypatch, engine, session_factory):
"""Run the real ``app.seed`` against the in-memory test database.
``app.seed`` opens its own engine and session factory, so both module-level
names are swapped for the test ones. The seed code itself is untouched —
testing a copy of it would prove nothing.
"""
import app.seed as seed_module
monkeypatch.setattr(seed_module, "engine", engine)
monkeypatch.setattr(seed_module, "SessionFactory", session_factory)
await seed_module.seed()
return seed_module
async def test_seeded_demo_user_can_log_in(client: AsyncClient, seeded_install):
"""The credentials the seed prints must actually work."""
response = await client.post(
"/api/auth/login",
json={
"email": seeded_install.DEMO_EMAIL,
"password": seeded_install.DEMO_PASSWORD,
},
)
assert response.status_code == 200, (
f"the seeded demo account cannot log in: {response.text}"
)
assert response.json()["access_token"]
async def test_seeded_platform_owner_can_log_in(client: AsyncClient, seeded_install):
response = await client.post(
"/api/auth/login",
json={
"email": seeded_install.PLATFORM_OWNER_EMAIL,
"password": seeded_install.PLATFORM_OWNER_PASSWORD,
},
)
assert response.status_code == 200, response.text
me = await client.get(
"/api/auth/me", headers=auth_header(response.json()["access_token"])
)
assert me.status_code == 200, me.text
assert me.json()["user"]["is_platform_owner"] is True
async def test_seeded_demo_manager_owns_the_sample_organization(
client: AsyncClient, seeded_install
):
"""A login that lands on an empty dashboard is a broken demo.
The demo account is a *manager* — the tier that creates organizations — so
it must own the sample organization and land on a populated dashboard.
"""
login = await client.post(
"/api/auth/login",
json={
"email": seeded_install.DEMO_EMAIL,
"password": seeded_install.DEMO_PASSWORD,
},
)
assert login.status_code == 200, login.text
assert login.json()["user"]["platform_role"] == "manager"
assert login.json()["user"]["is_manager"] is True
assert login.json()["user"]["is_platform_owner"] is False
me = await client.get(
"/api/auth/me", headers=auth_header(login.json()["access_token"])
)
organizations = me.json()["organizations"]
assert organizations, "the demo manager belongs to no organization"
assert organizations[0]["name"] == seeded_install.DEMO_ORG_NAME
assert organizations[0]["role"] == "owner"
async def test_platform_owner_is_not_joined_to_any_organization(
client: AsyncClient, seeded_install
):
"""The product rule: the owner observes tenants from the console.
Auto-joining the owner to a tenant would put the single owner seat inside
customer data, which is exactly what the hierarchy is designed to avoid.
"""
login = await client.post(
"/api/auth/login",
json={
"email": seeded_install.PLATFORM_OWNER_EMAIL,
"password": seeded_install.PLATFORM_OWNER_PASSWORD,
},
)
assert login.status_code == 200, login.text
me = await client.get(
"/api/auth/me", headers=auth_header(login.json()["access_token"])
)
assert me.status_code == 200, me.text
assert me.json()["user"]["is_platform_owner"] is True
assert me.json()["user"]["platform_role"] == "owner"
assert me.json()["organizations"] == []
async def test_seeded_install_can_use_the_product(
client: AsyncClient, seeded_install
):
"""The seed's platform rows are enough to create a project and run a task.
A seed that satisfies the login test but leaves the catalogue empty would
still fail the moment a developer tried anything real.
"""
login = await client.post(
"/api/auth/login",
json={
"email": seeded_install.DEMO_EMAIL,
"password": seeded_install.DEMO_PASSWORD,
},
)
headers = auth_header(login.json()["access_token"])
org_id = (await client.get("/api/auth/me", headers=headers)).json()["organizations"][0]["id"]
# The platform catalogue the seed installs must be visible to a tenant.
templates = await client.get(
f"/api/organizations/{org_id}/agent-templates", headers=headers
)
assert templates.status_code == 200, templates.text
assert templates.json(), "the seed must install agent templates"
sandbox_templates = await client.get(
f"/api/organizations/{org_id}/sandbox/templates", headers=headers
)
assert sandbox_templates.json(), "the seed must install sandbox templates"
project_id = (
await client.post(
f"/api/organizations/{org_id}/projects",
json={"name": "پروژهٔ seed"},
headers=headers,
)
).json()["id"]
template = templates.json()[0]
employee_id = (
await client.post(
f"/api/organizations/{org_id}/employees",
json={
"name": "کارمند seed",
"role_key": template["role_key"],
"template_id": template["id"],
},
headers=headers,
)
).json()["id"]
task_id = (
await client.post(
f"/api/organizations/{org_id}/tasks",
json={
"title": "کار seed",
"project_id": project_id,
"assigned_employee_id": employee_id,
},
headers=headers,
)
).json()["id"]
run = await client.post(
f"/api/organizations/{org_id}/tasks/{task_id}/runs",
json={"employee_id": employee_id},
headers=headers,
)
assert run.status_code in (200, 201), run.text
assert run.json()["status"] == "completed", (
"a seeded install must be able to route to a model and finish a run"
)
async def test_seed_is_idempotent(client: AsyncClient, seeded_install, session_factory):
"""Re-running the seed must not duplicate anything.
The memberships and the wallet are now created by get-or-create rather than
as a side effect of creating the organization, so this guards the parts that
were easy to double up.
"""
import app.seed as seed_module
from app.infrastructure.db.models import (
AgentTemplateRow,
CreditWalletRow,
MembershipRow,
OrganizationRow,
UserRow,
)
async def counts() -> dict[str, int]:
async with session_factory() as session:
return {
"organizations": int(
(await session.execute(select(func.count()).select_from(OrganizationRow)))
.scalar_one()
),
"memberships": int(
(await session.execute(select(func.count()).select_from(MembershipRow)))
.scalar_one()
),
"wallets": int(
(await session.execute(select(func.count()).select_from(CreditWalletRow)))
.scalar_one()
),
"users": int(
(await session.execute(select(func.count()).select_from(UserRow)))
.scalar_one()
),
"templates": int(
(await session.execute(select(func.count()).select_from(AgentTemplateRow)))
.scalar_one()
),
}
before = await counts()
await seed_module.seed()
after = await counts()
assert after == before, f"re-seeding changed row counts: {before} -> {after}"
assert after["organizations"] == 1
# Only the demo manager: the platform owner stays out of tenants.
assert after["memberships"] == 1