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:
2026-03-23 10:05:07 -06:00
parent ee2f88e13b
commit 7b348b97e9
6 changed files with 1149 additions and 0 deletions

View 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