Files
konstruct/tests/integration/test_portal_tenants.py
Adolfo Delorenzo 7b348b97e9 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
2026-03-23 10:05:07 -06:00

228 lines
8.7 KiB
Python

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