263 lines
10 KiB
Python
263 lines
10 KiB
Python
|
|
"""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}"
|