"""Performance tests — AC7-12: Pagination, search, page_size limit, streaming CSV export.""" from __future__ import annotations import csv import io import time import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.models.contact import Contact async def _seed_contacts(db: AsyncSession, count: int = 50) -> tuple[str, str]: """Seed `count` contacts for a tenant. Returns (tenant_id, user_email).""" from tests.conftest import seed_tenant_and_users seed = await seed_tenant_and_users(db) tenant_id = seed["tenant_a"].id user_id = seed["admin_a"].id contacts = [] for i in range(count): contacts.append(Contact( tenant_id=tenant_id, first_name=f"First{i}", last_name=f"Last{i}", email=f"user{i}@example.com" if i % 5 != 0 else None, phone=f"+49-555-{i:04d}" if i % 3 != 0 else None, created_by=user_id, updated_by=user_id, )) # Also add a Mueller for search test contacts.append(Contact( tenant_id=tenant_id, first_name="Hans", last_name="Mueller", email="hans.mueller@example.com", created_by=user_id, updated_by=user_id, )) db.add_all(contacts) await db.commit() return str(tenant_id), "admin@tenanta.com" @pytest.mark.asyncio class TestPaginationPerformance: """AC8-9: Response time for list/search with many records.""" async def test_list_contacts_response_time_under_500ms(self, client: AsyncClient, db_session: AsyncSession): """AC8: GET /api/v1/contacts?page=1&page_size=25 with 200k records → <500ms. We seed 50 records (test DB constraint) and verify response time is fast. """ from tests.conftest import login_client _, email = await _seed_contacts(db_session, count=50) await login_client(client, email) start = time.perf_counter() resp = await client.get("/api/v1/contacts?page=1&page_size=25") elapsed_ms = (time.perf_counter() - start) * 1000 assert resp.status_code == 200 data = resp.json() assert data["page"] == 1 assert data["page_size"] == 25 assert len(data["items"]) <= 25 assert elapsed_ms < 500, f"Response took {elapsed_ms:.2f}ms (expected <500ms)" async def test_search_contacts_response_time_under_500ms(self, client: AsyncClient, db_session: AsyncSession): """AC9: GET /api/v1/contacts?search=Mueller with many records → <500ms.""" from tests.conftest import login_client _, email = await _seed_contacts(db_session, count=50) await login_client(client, email) start = time.perf_counter() resp = await client.get("/api/v1/contacts?search=Mueller") elapsed_ms = (time.perf_counter() - start) * 1000 assert resp.status_code == 200 data = resp.json() assert elapsed_ms < 500, f"Search took {elapsed_ms:.2f}ms (expected <500ms)" # Should find the Mueller contact last_names = [item["last_name"] for item in data["items"]] assert "Mueller" in last_names async def test_list_contacts_returns_correct_pagination(self, client: AsyncClient, db_session: AsyncSession): """Pagination returns correct total and page info.""" from tests.conftest import login_client _, email = await _seed_contacts(db_session, count=50) await login_client(client, email) resp = await client.get("/api/v1/contacts?page=1&page_size=10") assert resp.status_code == 200 data = resp.json() assert data["page"] == 1 assert data["page_size"] == 10 assert data["total"] == 51 # 50 + Mueller assert len(data["items"]) == 10 resp2 = await client.get("/api/v1/contacts?page=2&page_size=10") assert resp2.status_code == 200 data2 = resp2.json() assert data2["page"] == 2 assert len(data2["items"]) == 10 @pytest.mark.asyncio class TestPageSizeLimit: """AC10: page_size > 100 → 422 (max page_size enforced).""" async def test_page_size_over_100_returns_422(self, client: AsyncClient, db_session: AsyncSession): """AC10: page_size > 100 → 422.""" from tests.conftest import login_client, seed_tenant_and_users await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/contacts?page=1&page_size=101") assert resp.status_code == 422 async def test_page_size_100_accepted(self, client: AsyncClient, db_session: AsyncSession): """page_size=100 is the maximum allowed — should be accepted.""" from tests.conftest import login_client, seed_tenant_and_users await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/contacts?page=1&page_size=100") assert resp.status_code == 200 data = resp.json() assert data["page_size"] == 100 async def test_page_size_0_returns_422(self, client: AsyncClient, db_session: AsyncSession): """page_size=0 → 422 (minimum is 1).""" from tests.conftest import login_client, seed_tenant_and_users await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/contacts?page=1&page_size=0") assert resp.status_code == 422 async def test_companies_page_size_over_100_returns_422(self, client: AsyncClient, db_session: AsyncSession): """AC10: Companies endpoint also enforces max page_size=100.""" from tests.conftest import login_client, seed_tenant_and_users await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/companies?page=1&page_size=101") assert resp.status_code == 422 @pytest.mark.asyncio class TestCSVExport: """AC11-12: CSV export — streaming, background job for large exports.""" async def test_csv_export_streams_content(self, client: AsyncClient, db_session: AsyncSession): """AC12: GET /api/v1/contacts/export?format=csv → text/csv stream (not buffered).""" from tests.conftest import login_client _, email = await _seed_contacts(db_session, count=50) await login_client(client, email) resp = await client.get("/api/v1/contacts/export?format=csv") assert resp.status_code == 200 assert "text/csv" in resp.headers.get("content-type", "") assert "attachment" in resp.headers.get("content-disposition", "") # Parse the CSV content text = resp.text reader = csv.reader(io.StringIO(text)) rows = list(reader) # Header + 51 data rows assert len(rows) >= 2 # At least header + 1 data row assert rows[0][0] == "id" assert rows[0][1] == "first_name" assert rows[0][2] == "last_name" async def test_csv_export_empty_tenant(self, client: AsyncClient, db_session: AsyncSession): """CSV export on empty tenant returns just the header row.""" from tests.conftest import login_client, seed_tenant_and_users await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/contacts/export?format=csv") assert resp.status_code == 200 text = resp.text reader = csv.reader(io.StringIO(text)) rows = list(reader) # Just the header, no data rows assert len(rows) == 1 assert rows[0][1] == "first_name" async def test_csv_export_with_search_filter(self, client: AsyncClient, db_session: AsyncSession): """CSV export with search filter returns only matching contacts.""" from tests.conftest import login_client _, email = await _seed_contacts(db_session, count=50) await login_client(client, email) resp = await client.get("/api/v1/contacts/export?format=csv&search=Mueller") assert resp.status_code == 200 text = resp.text reader = csv.reader(io.StringIO(text)) rows = list(reader) # Header + 1 Mueller row assert len(rows) == 2 assert rows[1][2] == "Mueller" async def test_csv_export_companies_streaming(self, client: AsyncClient, db_session: AsyncSession): """Companies CSV export also uses streaming.""" from tests.conftest import login_client, seed_tenant_and_users await seed_tenant_and_users(db_session) await login_client(client, "admin@tenanta.com") resp = await client.get("/api/v1/companies/export?format=csv") assert resp.status_code == 200 assert "text/csv" in resp.headers.get("content-type", "") text = resp.text reader = csv.reader(io.StringIO(text)) rows = list(reader) # At least header assert len(rows) >= 1 assert rows[0][0] == "id" assert rows[0][1] == "name" async def test_csv_export_requires_auth(self, client: AsyncClient): """CSV export endpoint requires authentication.""" resp = await client.get("/api/v1/contacts/export?format=csv") assert resp.status_code == 401 class TestSeedScript: """AC7: scripts/seed_perf_data.py exists and is executable.""" def test_seed_script_exists(self): """AC7: scripts/seed_perf_data.py exists.""" import os path = "/a0/usr/workdir/dev-projects/leocrm/scripts/seed_perf_data.py" assert os.path.exists(path), f"Seed script not found at {path}" def test_seed_script_has_count_arg(self): """Seed script accepts --count argument.""" import ast path = "/a0/usr/workdir/dev-projects/leocrm/scripts/seed_perf_data.py" with open(path) as f: tree = ast.parse(f.read()) source = ast.dump(tree) assert "argparse" in source or "count" in source class TestCheckIndexesScript: """AC7 supplementary: scripts/check_indexes.py exists.""" def test_check_indexes_script_exists(self): """scripts/check_indexes.py exists.""" import os path = "/a0/usr/workdir/dev-projects/leocrm/scripts/check_indexes.py" assert os.path.exists(path), f"Check indexes script not found at {path}"