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