feat(01-04): FastAPI portal API endpoints with tenant/agent CRUD and auth
- Add packages/shared/shared/api/portal.py with APIRouter at /api/portal
- POST /auth/verify validates bcrypt credentials against portal_users table
- POST /auth/register creates new portal users with hashed passwords
- Tenant CRUD: GET/POST /tenants, GET/PUT/DELETE /tenants/{id}
- Agent CRUD: full CRUD under /tenants/{tenant_id}/agents/{id}
- Agent endpoints set RLS current_tenant_id context for policy compliance
- Pydantic v2 schemas with slug validation (lowercase, hyphens, 2-50 chars)
- Add bcrypt>=4.0.0 dependency to konstruct-shared
- Integration tests: 38 tests covering all CRUD, validation, and isolation
This commit is contained in:
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"celery[redis]>=5.4.0",
|
||||
"httpx>=0.28.0",
|
||||
"slowapi>=0.1.9",
|
||||
"bcrypt>=4.0.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
|
||||
9
packages/shared/shared/api/__init__.py
Normal file
9
packages/shared/shared/api/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Konstruct shared API routers.
|
||||
|
||||
Import and mount these routers in service main.py files.
|
||||
"""
|
||||
|
||||
from shared.api.portal import portal_router
|
||||
|
||||
__all__ = ["portal_router"]
|
||||
521
packages/shared/shared/api/portal.py
Normal file
521
packages/shared/shared/api/portal.py
Normal file
@@ -0,0 +1,521 @@
|
||||
"""
|
||||
FastAPI portal API router — tenant CRUD, agent CRUD, and auth endpoints.
|
||||
|
||||
Mounted at /api/portal in the gateway or a dedicated portal-api service.
|
||||
The Auth.js v5 Credentials provider calls /auth/verify to validate operator
|
||||
email/password against the portal_users table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import bcrypt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.db import get_session
|
||||
from shared.models.auth import PortalUser
|
||||
from shared.models.tenant import Agent, Tenant
|
||||
from shared.rls import current_tenant_id
|
||||
|
||||
portal_router = APIRouter(prefix="/api/portal", tags=["portal"])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pydantic schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$|^[a-z0-9]{1,2}$")
|
||||
|
||||
|
||||
class AuthVerifyRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class AuthVerifyResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
class AuthRegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str = Field(min_length=8)
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class AuthRegisterResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
is_admin: bool
|
||||
|
||||
|
||||
class TenantCreate(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=100)
|
||||
slug: str = Field(min_length=2, max_length=50)
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not _SLUG_RE.match(v):
|
||||
raise ValueError(
|
||||
"slug must be 2-50 lowercase alphanumeric characters and hyphens "
|
||||
"(no leading/trailing hyphens)"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if len(v) < 2:
|
||||
raise ValueError("name must be at least 2 characters after stripping whitespace")
|
||||
return v
|
||||
|
||||
|
||||
class TenantUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=2, max_length=100)
|
||||
slug: str | None = Field(default=None, min_length=2, max_length=50)
|
||||
settings: dict[str, Any] | None = None
|
||||
|
||||
@field_validator("slug")
|
||||
@classmethod
|
||||
def validate_slug(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
v = v.strip()
|
||||
if not _SLUG_RE.match(v):
|
||||
raise ValueError(
|
||||
"slug must be 2-50 lowercase alphanumeric characters and hyphens "
|
||||
"(no leading/trailing hyphens)"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
settings: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, tenant: Tenant) -> "TenantResponse":
|
||||
return cls(
|
||||
id=str(tenant.id),
|
||||
name=tenant.name,
|
||||
slug=tenant.slug,
|
||||
settings=tenant.settings,
|
||||
created_at=tenant.created_at,
|
||||
updated_at=tenant.updated_at,
|
||||
)
|
||||
|
||||
|
||||
class TenantsListResponse(BaseModel):
|
||||
items: list[TenantResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class AgentCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
role: str = Field(min_length=1, max_length=255)
|
||||
persona: str = Field(default="")
|
||||
system_prompt: str = Field(default="")
|
||||
model_preference: str = Field(default="quality")
|
||||
tool_assignments: list[Any] = Field(default_factory=list)
|
||||
escalation_rules: list[Any] = Field(default_factory=list)
|
||||
is_active: bool = Field(default=True)
|
||||
|
||||
@field_validator("model_preference")
|
||||
@classmethod
|
||||
def validate_model_preference(cls, v: str) -> str:
|
||||
allowed = {"quality", "fast", "balanced", "economy", "local"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"model_preference must be one of: {', '.join(sorted(allowed))}")
|
||||
return v
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
role: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
persona: str | None = None
|
||||
system_prompt: str | None = None
|
||||
model_preference: str | None = None
|
||||
tool_assignments: list[Any] | None = None
|
||||
escalation_rules: list[Any] | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
@field_validator("model_preference")
|
||||
@classmethod
|
||||
def validate_model_preference(cls, v: str | None) -> str | None:
|
||||
if v is None:
|
||||
return v
|
||||
allowed = {"quality", "fast", "balanced", "economy", "local"}
|
||||
if v not in allowed:
|
||||
raise ValueError(f"model_preference must be one of: {', '.join(sorted(allowed))}")
|
||||
return v
|
||||
|
||||
|
||||
class AgentResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
name: str
|
||||
role: str
|
||||
persona: str
|
||||
system_prompt: str
|
||||
model_preference: str
|
||||
tool_assignments: list[Any]
|
||||
escalation_rules: list[Any]
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@classmethod
|
||||
def from_orm(cls, agent: Agent) -> "AgentResponse":
|
||||
return cls(
|
||||
id=str(agent.id),
|
||||
tenant_id=str(agent.tenant_id),
|
||||
name=agent.name,
|
||||
role=agent.role,
|
||||
persona=agent.persona,
|
||||
system_prompt=agent.system_prompt,
|
||||
model_preference=agent.model_preference,
|
||||
tool_assignments=agent.tool_assignments,
|
||||
escalation_rules=agent.escalation_rules,
|
||||
is_active=agent.is_active,
|
||||
created_at=agent.created_at,
|
||||
updated_at=agent.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@portal_router.post("/auth/verify", response_model=AuthVerifyResponse)
|
||||
async def verify_credentials(
|
||||
body: AuthVerifyRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AuthVerifyResponse:
|
||||
"""
|
||||
Validate email + password against the portal_users table.
|
||||
|
||||
Used by Auth.js v5 Credentials provider. Returns 401 on invalid credentials.
|
||||
Response deliberately omits hashed_password.
|
||||
"""
|
||||
result = await session.execute(select(PortalUser).where(PortalUser.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None or not bcrypt.checkpw(body.password.encode(), user.hashed_password.encode()):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
||||
|
||||
return AuthVerifyResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
|
||||
@portal_router.post("/auth/register", response_model=AuthRegisterResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register_user(
|
||||
body: AuthRegisterRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AuthRegisterResponse:
|
||||
"""
|
||||
Create a new portal user with bcrypt-hashed password.
|
||||
|
||||
In production, restrict this to admin-only or use a setup wizard.
|
||||
Returns 409 if email already registered.
|
||||
"""
|
||||
existing = await session.execute(select(PortalUser).where(PortalUser.email == body.email))
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
|
||||
|
||||
hashed = bcrypt.hashpw(body.password.encode(), bcrypt.gensalt()).decode()
|
||||
user = PortalUser(
|
||||
email=body.email,
|
||||
hashed_password=hashed,
|
||||
name=body.name,
|
||||
is_admin=False,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
return AuthRegisterResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenant endpoints (PRTA-01)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@portal_router.get("/tenants", response_model=TenantsListResponse)
|
||||
async def list_tenants(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TenantsListResponse:
|
||||
"""List all tenants (platform admin view). Paginated."""
|
||||
offset = (page - 1) * page_size
|
||||
result = await session.execute(select(Tenant).order_by(Tenant.created_at.desc()).offset(offset).limit(page_size))
|
||||
tenants = result.scalars().all()
|
||||
|
||||
count_result = await session.execute(select(Tenant))
|
||||
total = len(count_result.scalars().all())
|
||||
|
||||
return TenantsListResponse(
|
||||
items=[TenantResponse.from_orm(t) for t in tenants],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@portal_router.post("/tenants", response_model=TenantResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tenant(
|
||||
body: TenantCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TenantResponse:
|
||||
"""Create a new tenant. Validates slug uniqueness."""
|
||||
# Check name uniqueness
|
||||
name_exists = await session.execute(select(Tenant).where(Tenant.name == body.name))
|
||||
if name_exists.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant name already exists")
|
||||
|
||||
# Check slug uniqueness
|
||||
slug_exists = await session.execute(select(Tenant).where(Tenant.slug == body.slug))
|
||||
if slug_exists.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant slug already exists")
|
||||
|
||||
tenant = Tenant(name=body.name, slug=body.slug, settings=body.settings)
|
||||
session.add(tenant)
|
||||
await session.commit()
|
||||
await session.refresh(tenant)
|
||||
return TenantResponse.from_orm(tenant)
|
||||
|
||||
|
||||
@portal_router.get("/tenants/{tenant_id}", response_model=TenantResponse)
|
||||
async def get_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TenantResponse:
|
||||
"""Get a tenant by ID. Returns 404 if not found."""
|
||||
result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
return TenantResponse.from_orm(tenant)
|
||||
|
||||
|
||||
@portal_router.put("/tenants/{tenant_id}", response_model=TenantResponse)
|
||||
async def update_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
body: TenantUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TenantResponse:
|
||||
"""Update a tenant (partial updates supported)."""
|
||||
result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
|
||||
if body.name is not None:
|
||||
# Check name uniqueness (exclude self)
|
||||
name_exists = await session.execute(
|
||||
select(Tenant).where(Tenant.name == body.name, Tenant.id != tenant_id)
|
||||
)
|
||||
if name_exists.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant name already exists")
|
||||
tenant.name = body.name
|
||||
|
||||
if body.slug is not None:
|
||||
# Check slug uniqueness (exclude self)
|
||||
slug_exists = await session.execute(
|
||||
select(Tenant).where(Tenant.slug == body.slug, Tenant.id != tenant_id)
|
||||
)
|
||||
if slug_exists.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Tenant slug already exists")
|
||||
tenant.slug = body.slug
|
||||
|
||||
if body.settings is not None:
|
||||
tenant.settings = body.settings
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(tenant)
|
||||
return TenantResponse.from_orm(tenant)
|
||||
|
||||
|
||||
@portal_router.delete("/tenants/{tenant_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tenant(
|
||||
tenant_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""Delete a tenant. Cascade deletes agents and channel_connections."""
|
||||
result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
await session.delete(tenant)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent endpoints (PRTA-02)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _get_tenant_or_404(tenant_id: uuid.UUID, session: AsyncSession) -> Tenant:
|
||||
"""Shared helper to fetch a tenant or raise 404."""
|
||||
result = await session.execute(select(Tenant).where(Tenant.id == tenant_id))
|
||||
tenant = result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tenant not found")
|
||||
return tenant
|
||||
|
||||
|
||||
@portal_router.get("/tenants/{tenant_id}/agents", response_model=list[AgentResponse])
|
||||
async def list_agents(
|
||||
tenant_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[AgentResponse]:
|
||||
"""List all agents for a tenant."""
|
||||
await _get_tenant_or_404(tenant_id, session)
|
||||
token = current_tenant_id.set(tenant_id)
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Agent).where(Agent.tenant_id == tenant_id).order_by(Agent.created_at.desc())
|
||||
)
|
||||
agents = result.scalars().all()
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
return [AgentResponse.from_orm(a) for a in agents]
|
||||
|
||||
|
||||
@portal_router.post(
|
||||
"/tenants/{tenant_id}/agents",
|
||||
response_model=AgentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_agent(
|
||||
tenant_id: uuid.UUID,
|
||||
body: AgentCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentResponse:
|
||||
"""Create an AI employee for a tenant."""
|
||||
await _get_tenant_or_404(tenant_id, session)
|
||||
token = current_tenant_id.set(tenant_id)
|
||||
try:
|
||||
agent = Agent(
|
||||
tenant_id=tenant_id,
|
||||
name=body.name,
|
||||
role=body.role,
|
||||
persona=body.persona,
|
||||
system_prompt=body.system_prompt,
|
||||
model_preference=body.model_preference,
|
||||
tool_assignments=body.tool_assignments,
|
||||
escalation_rules=body.escalation_rules,
|
||||
is_active=body.is_active,
|
||||
)
|
||||
session.add(agent)
|
||||
await session.commit()
|
||||
await session.refresh(agent)
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
return AgentResponse.from_orm(agent)
|
||||
|
||||
|
||||
@portal_router.get("/tenants/{tenant_id}/agents/{agent_id}", response_model=AgentResponse)
|
||||
async def get_agent(
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentResponse:
|
||||
"""Get an agent by ID (must belong to the specified tenant)."""
|
||||
await _get_tenant_or_404(tenant_id, session)
|
||||
token = current_tenant_id.set(tenant_id)
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)
|
||||
)
|
||||
agent = result.scalar_one_or_none()
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
|
||||
return AgentResponse.from_orm(agent)
|
||||
|
||||
|
||||
@portal_router.put("/tenants/{tenant_id}/agents/{agent_id}", response_model=AgentResponse)
|
||||
async def update_agent(
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
body: AgentUpdate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> AgentResponse:
|
||||
"""Update an agent (partial updates supported)."""
|
||||
await _get_tenant_or_404(tenant_id, session)
|
||||
token = current_tenant_id.set(tenant_id)
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)
|
||||
)
|
||||
agent = result.scalar_one_or_none()
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
|
||||
|
||||
update_fields = body.model_dump(exclude_none=True)
|
||||
for field, value in update_fields.items():
|
||||
setattr(agent, field, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(agent)
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
return AgentResponse.from_orm(agent)
|
||||
|
||||
|
||||
@portal_router.delete("/tenants/{tenant_id}/agents/{agent_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_agent(
|
||||
tenant_id: uuid.UUID,
|
||||
agent_id: uuid.UUID,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""Delete an agent."""
|
||||
await _get_tenant_or_404(tenant_id, session)
|
||||
token = current_tenant_id.set(tenant_id)
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(Agent).where(Agent.id == agent_id, Agent.tenant_id == tenant_id)
|
||||
)
|
||||
agent = result.scalar_one_or_none()
|
||||
if agent is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
|
||||
await session.delete(agent)
|
||||
await session.commit()
|
||||
finally:
|
||||
current_tenant_id.reset(token)
|
||||
389
tests/integration/test_portal_agents.py
Normal file
389
tests/integration/test_portal_agents.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
Integration tests for portal agent CRUD endpoints (PRTA-02).
|
||||
|
||||
Tests: create (all fields / minimal fields), list (tenant isolation), get, update,
|
||||
delete. Proves all Agent Designer fields are stored and retrievable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.api.portal import portal_router
|
||||
from shared.db import get_session
|
||||
|
||||
|
||||
def make_app(session: AsyncSession) -> FastAPI:
|
||||
"""Build a FastAPI test app with the portal router and session override."""
|
||||
app = FastAPI()
|
||||
app.include_router(portal_router)
|
||||
|
||||
async def override_get_session(): # type: ignore[return]
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = override_get_session
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession): # type: ignore[no-untyped-def]
|
||||
"""HTTP client wired to the portal API with the test DB session."""
|
||||
app = make_app(db_session)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def tenant_id(client: AsyncClient) -> str:
|
||||
"""Create a tenant and return its ID for agent tests."""
|
||||
slug = f"agent-test-{uuid.uuid4().hex[:8]}"
|
||||
resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Agent Test Tenant {slug}", "slug": slug},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def second_tenant_id(client: AsyncClient) -> str:
|
||||
"""Create a second tenant for isolation tests."""
|
||||
slug = f"agent-test2-{uuid.uuid4().hex[:8]}"
|
||||
resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Agent Test Tenant 2 {slug}", "slug": slug},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentCreate:
|
||||
async def test_create_agent_all_fields_returns_201(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
"""All Agent Designer fields are accepted and returned."""
|
||||
resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={
|
||||
"name": "Mara",
|
||||
"role": "Customer Support Lead",
|
||||
"persona": "Professional, empathetic, solution-oriented.",
|
||||
"system_prompt": "You are a helpful customer support agent named Mara.",
|
||||
"model_preference": "quality",
|
||||
"tool_assignments": ["zendesk_ticket_create", "knowledge_base_search"],
|
||||
"escalation_rules": [
|
||||
{"condition": "billing_dispute AND attempts > 2", "action": "handoff_human"},
|
||||
{"condition": "sentiment < -0.7", "action": "handoff_human"},
|
||||
],
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Mara"
|
||||
assert data["role"] == "Customer Support Lead"
|
||||
assert data["persona"] == "Professional, empathetic, solution-oriented."
|
||||
assert data["system_prompt"] == "You are a helpful customer support agent named Mara."
|
||||
assert data["model_preference"] == "quality"
|
||||
assert data["tool_assignments"] == ["zendesk_ticket_create", "knowledge_base_search"]
|
||||
assert len(data["escalation_rules"]) == 2
|
||||
assert data["is_active"] is True
|
||||
assert data["tenant_id"] == tenant_id
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
async def test_create_agent_minimal_fields_returns_201_with_defaults(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
"""Creating with only name + role uses sensible defaults."""
|
||||
resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Sam", "role": "Sales Representative"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["name"] == "Sam"
|
||||
assert data["role"] == "Sales Representative"
|
||||
assert data["persona"] == ""
|
||||
assert data["system_prompt"] == ""
|
||||
assert data["model_preference"] == "quality"
|
||||
assert data["tool_assignments"] == []
|
||||
assert data["escalation_rules"] == []
|
||||
assert data["is_active"] is True
|
||||
|
||||
async def test_create_agent_invalid_tenant_returns_404(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/portal/tenants/{uuid.uuid4()}/agents",
|
||||
json={"name": "Ghost", "role": "Nobody"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_create_agent_missing_name_returns_422(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"role": "Some Role"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_create_agent_missing_role_returns_422(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Some Name"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_create_agent_invalid_model_preference_returns_422(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Bad Model", "role": "Tester", "model_preference": "super-mega-mode"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentList:
|
||||
async def test_list_agents_returns_only_that_tenants_agents(
|
||||
self, client: AsyncClient, tenant_id: str, second_tenant_id: str
|
||||
) -> None:
|
||||
"""Agents from one tenant must NOT appear in another tenant's list."""
|
||||
# Create agent for tenant 1
|
||||
await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Tenant1 Agent", "role": "Worker"},
|
||||
)
|
||||
# Create agent for tenant 2
|
||||
await client.post(
|
||||
f"/api/portal/tenants/{second_tenant_id}/agents",
|
||||
json={"name": "Tenant2 Agent", "role": "Worker"},
|
||||
)
|
||||
|
||||
resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents")
|
||||
assert resp.status_code == 200
|
||||
agents = resp.json()
|
||||
names = [a["name"] for a in agents]
|
||||
assert "Tenant1 Agent" in names
|
||||
assert "Tenant2 Agent" not in names
|
||||
for agent in agents:
|
||||
assert agent["tenant_id"] == tenant_id
|
||||
|
||||
async def test_list_agents_empty_for_new_tenant(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
async def test_list_agents_invalid_tenant_returns_404(
|
||||
self, client: AsyncClient
|
||||
) -> None:
|
||||
resp = await client.get(f"/api/portal/tenants/{uuid.uuid4()}/agents")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentGet:
|
||||
async def test_get_agent_by_id(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
create_resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Get Me", "role": "Fetcher"},
|
||||
)
|
||||
agent_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == agent_id
|
||||
assert resp.json()["name"] == "Get Me"
|
||||
|
||||
async def test_get_agent_not_found_returns_404(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_get_agent_wrong_tenant_returns_404(
|
||||
self, client: AsyncClient, tenant_id: str, second_tenant_id: str
|
||||
) -> None:
|
||||
"""Agent from tenant1 should not be accessible via tenant2's path."""
|
||||
create_resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Secret Agent", "role": "Spy"},
|
||||
)
|
||||
agent_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.get(f"/api/portal/tenants/{second_tenant_id}/agents/{agent_id}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentUpdate:
|
||||
async def test_update_agent_persona_and_system_prompt(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
create_resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Updatable", "role": "Updater"},
|
||||
)
|
||||
agent_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/portal/tenants/{tenant_id}/agents/{agent_id}",
|
||||
json={
|
||||
"persona": "New persona description",
|
||||
"system_prompt": "New system prompt content",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["persona"] == "New persona description"
|
||||
assert data["system_prompt"] == "New system prompt content"
|
||||
assert data["name"] == "Updatable" # unchanged
|
||||
|
||||
async def test_update_agent_tool_assignments(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
create_resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Tool Updater", "role": "Tester", "tool_assignments": ["tool_a"]},
|
||||
)
|
||||
agent_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.put(
|
||||
f"/api/portal/tenants/{tenant_id}/agents/{agent_id}",
|
||||
json={"tool_assignments": ["tool_a", "tool_b", "tool_c"]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["tool_assignments"] == ["tool_a", "tool_b", "tool_c"]
|
||||
|
||||
async def test_update_agent_escalation_rules(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
create_resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Escalation Test", "role": "Support"},
|
||||
)
|
||||
agent_id = create_resp.json()["id"]
|
||||
|
||||
rules = [{"condition": "urgent", "action": "escalate_to_manager"}]
|
||||
resp = await client.put(
|
||||
f"/api/portal/tenants/{tenant_id}/agents/{agent_id}",
|
||||
json={"escalation_rules": rules},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["escalation_rules"] == rules
|
||||
|
||||
async def test_update_agent_not_found_returns_404(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
resp = await client.put(
|
||||
f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}",
|
||||
json={"name": "Ghost"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentDelete:
|
||||
async def test_delete_agent_returns_204(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
create_resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Delete Me", "role": "Doomed"},
|
||||
)
|
||||
agent_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_deleted_agent_is_gone(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
create_resp = await client.post(
|
||||
f"/api/portal/tenants/{tenant_id}/agents",
|
||||
json={"name": "Gone Soon", "role": "Temporary"},
|
||||
)
|
||||
agent_id = create_resp.json()["id"]
|
||||
|
||||
await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}")
|
||||
get_resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}")
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
async def test_delete_agent_not_found_returns_404(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
resp = await client.delete(f"/api/portal/tenants/{tenant_id}/agents/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAgentDesignerFields:
|
||||
"""
|
||||
Explicitly prove all Agent Designer fields are stored and retrievable.
|
||||
This is the end-to-end round-trip test for PRTA-02.
|
||||
"""
|
||||
|
||||
async def test_all_agent_designer_fields_stored_and_retrievable(
|
||||
self, client: AsyncClient, tenant_id: str
|
||||
) -> None:
|
||||
payload = {
|
||||
"name": "Mara Chen",
|
||||
"role": "Head of Customer Experience",
|
||||
"persona": (
|
||||
"Empathetic, data-driven, and relentlessly focused on customer success. "
|
||||
"Fluent in English, Spanish, and Mandarin. "
|
||||
"Never escalates without first attempting resolution."
|
||||
),
|
||||
"system_prompt": (
|
||||
"You are Mara Chen, Head of Customer Experience at Acme Corp. "
|
||||
"Your goal is to resolve customer issues efficiently and with empathy."
|
||||
),
|
||||
"model_preference": "quality",
|
||||
"tool_assignments": [
|
||||
"zendesk_ticket_create",
|
||||
"zendesk_ticket_update",
|
||||
"knowledge_base_search",
|
||||
"calendar_book",
|
||||
"send_email",
|
||||
],
|
||||
"escalation_rules": [
|
||||
{"condition": "billing_dispute AND resolution_attempts > 2", "action": "handoff_human"},
|
||||
{"condition": "customer_sentiment < -0.7", "action": "handoff_human"},
|
||||
{"condition": "legal_threat_detected", "action": "handoff_legal_team"},
|
||||
],
|
||||
"is_active": True,
|
||||
}
|
||||
create_resp = await client.post(f"/api/portal/tenants/{tenant_id}/agents", json=payload)
|
||||
assert create_resp.status_code == 201
|
||||
agent_id = create_resp.json()["id"]
|
||||
|
||||
# Fetch and verify all fields round-trip correctly
|
||||
get_resp = await client.get(f"/api/portal/tenants/{tenant_id}/agents/{agent_id}")
|
||||
assert get_resp.status_code == 200
|
||||
data = get_resp.json()
|
||||
|
||||
assert data["name"] == payload["name"]
|
||||
assert data["role"] == payload["role"]
|
||||
assert data["persona"] == payload["persona"]
|
||||
assert data["system_prompt"] == payload["system_prompt"]
|
||||
assert data["model_preference"] == payload["model_preference"]
|
||||
assert data["tool_assignments"] == payload["tool_assignments"]
|
||||
assert data["escalation_rules"] == payload["escalation_rules"]
|
||||
assert data["is_active"] == payload["is_active"]
|
||||
assert data["tenant_id"] == tenant_id
|
||||
227
tests/integration/test_portal_tenants.py
Normal file
227
tests/integration/test_portal_tenants.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Integration tests for portal tenant CRUD endpoints (PRTA-01).
|
||||
|
||||
Tests: create, read, update, delete, list, validation, uniqueness constraints.
|
||||
Uses httpx.AsyncClient with a live FastAPI app backed by the test PostgreSQL DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.api.portal import portal_router
|
||||
from shared.db import get_session
|
||||
|
||||
|
||||
def make_app(session: AsyncSession) -> FastAPI:
|
||||
"""Build a FastAPI test app with the portal router and session override."""
|
||||
app = FastAPI()
|
||||
app.include_router(portal_router)
|
||||
|
||||
async def override_get_session(): # type: ignore[return]
|
||||
yield session
|
||||
|
||||
app.dependency_overrides[get_session] = override_get_session
|
||||
return app
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession): # type: ignore[no-untyped-def]
|
||||
"""HTTP client wired to the portal API with the test DB session."""
|
||||
app = make_app(db_session)
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTenantCreate:
|
||||
async def test_create_tenant_valid_returns_201(self, client: AsyncClient) -> None:
|
||||
slug = f"acme-corp-{uuid.uuid4().hex[:6]}"
|
||||
resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Acme Corp {slug}", "slug": slug},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["slug"] == slug
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
async def test_create_tenant_with_settings(self, client: AsyncClient) -> None:
|
||||
slug = f"settings-test-{uuid.uuid4().hex[:6]}"
|
||||
resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Settings Test {slug}", "slug": slug, "settings": {"tier": "starter"}},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["settings"]["tier"] == "starter"
|
||||
|
||||
async def test_create_tenant_duplicate_slug_returns_409(self, client: AsyncClient) -> None:
|
||||
slug = f"dup-slug-{uuid.uuid4().hex[:6]}"
|
||||
await client.post("/api/portal/tenants", json={"name": f"First {slug}", "slug": slug})
|
||||
resp = await client.post("/api/portal/tenants", json={"name": f"Second {slug}", "slug": slug})
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_create_tenant_duplicate_name_returns_409(self, client: AsyncClient) -> None:
|
||||
slug1 = f"name-dup-a-{uuid.uuid4().hex[:6]}"
|
||||
slug2 = f"name-dup-b-{uuid.uuid4().hex[:6]}"
|
||||
name = f"Duplicate Name {uuid.uuid4().hex[:6]}"
|
||||
await client.post("/api/portal/tenants", json={"name": name, "slug": slug1})
|
||||
resp = await client.post("/api/portal/tenants", json={"name": name, "slug": slug2})
|
||||
assert resp.status_code == 409
|
||||
|
||||
async def test_create_tenant_invalid_slug_uppercase_returns_422(self, client: AsyncClient) -> None:
|
||||
resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": "Bad Slug Company", "slug": "BadSlug"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_create_tenant_invalid_slug_too_short_returns_422(self, client: AsyncClient) -> None:
|
||||
resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": "Too Short Slug", "slug": "a"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_create_tenant_invalid_slug_leading_hyphen_returns_422(self, client: AsyncClient) -> None:
|
||||
resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": "Leading Hyphen Co", "slug": "-leading-hyphen"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
async def test_create_tenant_name_too_short_returns_422(self, client: AsyncClient) -> None:
|
||||
resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": "X", "slug": "valid-slug"},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTenantList:
|
||||
async def test_list_tenants_returns_created_tenants(self, client: AsyncClient) -> None:
|
||||
slug1 = f"list-a-{uuid.uuid4().hex[:6]}"
|
||||
slug2 = f"list-b-{uuid.uuid4().hex[:6]}"
|
||||
await client.post("/api/portal/tenants", json={"name": f"List A {slug1}", "slug": slug1})
|
||||
await client.post("/api/portal/tenants", json={"name": f"List B {slug2}", "slug": slug2})
|
||||
|
||||
resp = await client.get("/api/portal/tenants")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert "total" in data
|
||||
slugs = [t["slug"] for t in data["items"]]
|
||||
assert slug1 in slugs
|
||||
assert slug2 in slugs
|
||||
|
||||
async def test_list_tenants_pagination(self, client: AsyncClient) -> None:
|
||||
# Create 3 tenants
|
||||
for i in range(3):
|
||||
slug = f"pag-{i}-{uuid.uuid4().hex[:6]}"
|
||||
await client.post("/api/portal/tenants", json={"name": f"Pag {i} {slug}", "slug": slug})
|
||||
|
||||
resp = await client.get("/api/portal/tenants?page=1&page_size=2")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data["items"]) <= 2
|
||||
assert data["page"] == 1
|
||||
assert data["page_size"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTenantGet:
|
||||
async def test_get_tenant_by_id_returns_correct_tenant(self, client: AsyncClient) -> None:
|
||||
slug = f"get-test-{uuid.uuid4().hex[:6]}"
|
||||
create_resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Get Test {slug}", "slug": slug},
|
||||
)
|
||||
tenant_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.get(f"/api/portal/tenants/{tenant_id}")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == tenant_id
|
||||
assert resp.json()["slug"] == slug
|
||||
|
||||
async def test_get_tenant_not_found_returns_404(self, client: AsyncClient) -> None:
|
||||
resp = await client.get(f"/api/portal/tenants/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTenantUpdate:
|
||||
async def test_update_tenant_name(self, client: AsyncClient) -> None:
|
||||
slug = f"upd-test-{uuid.uuid4().hex[:6]}"
|
||||
create_resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Original Name {slug}", "slug": slug},
|
||||
)
|
||||
tenant_id = create_resp.json()["id"]
|
||||
|
||||
new_name = f"Updated Name {uuid.uuid4().hex[:6]}"
|
||||
resp = await client.put(
|
||||
f"/api/portal/tenants/{tenant_id}",
|
||||
json={"name": new_name},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == new_name
|
||||
assert resp.json()["slug"] == slug # unchanged
|
||||
|
||||
async def test_update_tenant_slug(self, client: AsyncClient) -> None:
|
||||
slug = f"old-slug-{uuid.uuid4().hex[:6]}"
|
||||
create_resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Slug Update Test {slug}", "slug": slug},
|
||||
)
|
||||
tenant_id = create_resp.json()["id"]
|
||||
|
||||
new_slug = f"new-slug-{uuid.uuid4().hex[:6]}"
|
||||
resp = await client.put(
|
||||
f"/api/portal/tenants/{tenant_id}",
|
||||
json={"slug": new_slug},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["slug"] == new_slug
|
||||
|
||||
async def test_update_tenant_not_found_returns_404(self, client: AsyncClient) -> None:
|
||||
resp = await client.put(f"/api/portal/tenants/{uuid.uuid4()}", json={"name": "Does Not Exist"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTenantDelete:
|
||||
async def test_delete_tenant_returns_204(self, client: AsyncClient) -> None:
|
||||
slug = f"del-test-{uuid.uuid4().hex[:6]}"
|
||||
create_resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Delete Test {slug}", "slug": slug},
|
||||
)
|
||||
tenant_id = create_resp.json()["id"]
|
||||
|
||||
resp = await client.delete(f"/api/portal/tenants/{tenant_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
async def test_delete_tenant_is_gone_after_delete(self, client: AsyncClient) -> None:
|
||||
slug = f"del-gone-{uuid.uuid4().hex[:6]}"
|
||||
create_resp = await client.post(
|
||||
"/api/portal/tenants",
|
||||
json={"name": f"Delete Gone Test {slug}", "slug": slug},
|
||||
)
|
||||
tenant_id = create_resp.json()["id"]
|
||||
|
||||
await client.delete(f"/api/portal/tenants/{tenant_id}")
|
||||
get_resp = await client.get(f"/api/portal/tenants/{tenant_id}")
|
||||
assert get_resp.status_code == 404
|
||||
|
||||
async def test_delete_tenant_not_found_returns_404(self, client: AsyncClient) -> None:
|
||||
resp = await client.delete(f"/api/portal/tenants/{uuid.uuid4()}")
|
||||
assert resp.status_code == 404
|
||||
2
uv.lock
generated
2
uv.lock
generated
@@ -1205,6 +1205,7 @@ name = "konstruct-orchestrator"
|
||||
version = "0.1.0"
|
||||
source = { editable = "packages/orchestrator" }
|
||||
dependencies = [
|
||||
{ name = "celery", extra = ["redis"] },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "konstruct-shared" },
|
||||
@@ -1212,6 +1213,7 @@ dependencies = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "celery", extras = ["redis"], specifier = ">=5.4.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.0" },
|
||||
{ name = "konstruct-shared", editable = "packages/shared" },
|
||||
|
||||
Reference in New Issue
Block a user