T10: Monitoring, Performance, Documentation & Environment Config — 38 tests, ruff clean, docs OK
- Extended health endpoint: DB+Redis+Storage+Worker checks with degraded status - Prometheus metrics endpoint: admin-only, text/plain format - Metrics: http_requests_total, db_pool_connections, arq_jobs_total - Structured JSON logging (structlog): timestamp, level, method, path, status, duration_ms, tenant_id - Performance: page_size max 100 enforced (422), streaming CSV export (StreamingResponse) - Scripts: seed_perf_data.py, check_indexes.py - Docs: admin-guide.md (Deploy, Backup, Restore, Env-Vars, Troubleshooting), api-overview.md - README updated: prod setup, API section, env profiles, admin-guide link - .env.example: added SECRET_KEY, STORAGE_PATH, SMTP_* vars - 38 new tests, full regression 564/564 pass (0 failures) - Ruff: all checks passed
This commit is contained in:
+44
-4
@@ -1,4 +1,4 @@
|
||||
"""Health endpoint test — AC 18."""
|
||||
"""Health endpoint test — AC1-2: Extended health check with DB, Redis, storage, worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,12 +8,52 @@ from httpx import AsyncClient
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestHealth:
|
||||
"""AC 18: GET /api/v1/health -> 200 without auth."""
|
||||
"""AC1: GET /api/v1/health -> 200 without auth, with extended checks."""
|
||||
|
||||
async def test_health_returns_200_without_auth(self, client: AsyncClient):
|
||||
"""AC 18: GET /api/v1/health -> 200 without auth."""
|
||||
"""AC1: GET /api/v1/health -> 200 without auth."""
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "status" in data
|
||||
assert "version" in data
|
||||
assert "checks" in data
|
||||
|
||||
async def test_health_has_database_check(self, client: AsyncClient):
|
||||
"""AC1: Health response includes checks.database."""
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "database" in data["checks"]
|
||||
assert "status" in data["checks"]["database"]
|
||||
|
||||
async def test_health_has_redis_check(self, client: AsyncClient):
|
||||
"""AC1: Health response includes checks.redis."""
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "redis" in data["checks"]
|
||||
assert "status" in data["checks"]["redis"]
|
||||
|
||||
async def test_health_has_storage_check(self, client: AsyncClient):
|
||||
"""AC1: Health response includes checks.storage."""
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "storage" in data["checks"]
|
||||
assert "status" in data["checks"]["storage"]
|
||||
|
||||
async def test_health_has_worker_check(self, client: AsyncClient):
|
||||
"""AC1: Health response includes checks.worker."""
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "worker" in data["checks"]
|
||||
assert "status" in data["checks"]["worker"]
|
||||
|
||||
async def test_health_status_is_valid_value(self, client: AsyncClient):
|
||||
"""Health status should be 'healthy' or 'degraded'."""
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] in ("healthy", "degraded")
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Monitoring tests — AC1-6: Health checks, Prometheus metrics, structured logging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core.monitoring import (
|
||||
check_database,
|
||||
check_redis,
|
||||
check_storage,
|
||||
check_worker,
|
||||
generate_metrics,
|
||||
record_arq_job,
|
||||
record_error,
|
||||
record_request,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestHealthChecks:
|
||||
"""AC1-2: Health endpoint with DB, Redis, storage, worker checks."""
|
||||
|
||||
async def test_health_returns_200_with_checks_structure(self, client: AsyncClient):
|
||||
"""AC1: GET /api/v1/health → 200 + JSON with status, checks.database, checks.redis,
|
||||
checks.storage, checks.worker."""
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "status" in data
|
||||
assert "checks" in data
|
||||
assert "database" in data["checks"]
|
||||
assert "redis" in data["checks"]
|
||||
assert "storage" in data["checks"]
|
||||
assert "worker" in data["checks"]
|
||||
# Each check should have a status field
|
||||
for check_name in ["database", "redis", "storage", "worker"]:
|
||||
assert "status" in data["checks"][check_name]
|
||||
|
||||
async def test_health_db_down_returns_degraded(self, client: AsyncClient):
|
||||
"""AC2: GET /api/v1/health with DB down → 200 + status=degraded,
|
||||
checks.database.status=down."""
|
||||
with patch("app.routes.health.get_health_status") as mock_health:
|
||||
mock_health.return_value = {
|
||||
"status": "degraded",
|
||||
"version": "1.0.0",
|
||||
"checks": {
|
||||
"database": {"status": "down", "error": "Connection refused"},
|
||||
"redis": {"status": "up"},
|
||||
"storage": {"status": "up"},
|
||||
"worker": {"status": "up"},
|
||||
},
|
||||
}
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["status"] == "degraded"
|
||||
assert data["checks"]["database"]["status"] == "down"
|
||||
|
||||
async def test_health_no_auth_required(self, client: AsyncClient):
|
||||
"""Health endpoint should work without authentication."""
|
||||
resp = await client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
|
||||
async def test_check_database_returns_dict(self, client: AsyncClient):
|
||||
"""check_database returns a dict with status field."""
|
||||
result = await check_database()
|
||||
assert isinstance(result, dict)
|
||||
assert "status" in result
|
||||
|
||||
async def test_check_redis_returns_dict(self, client: AsyncClient):
|
||||
"""check_redis returns a dict with status field."""
|
||||
result = await check_redis()
|
||||
assert isinstance(result, dict)
|
||||
assert "status" in result
|
||||
|
||||
async def test_check_storage_returns_dict(self, client: AsyncClient):
|
||||
"""check_storage returns a dict with status field."""
|
||||
result = await check_storage()
|
||||
assert isinstance(result, dict)
|
||||
assert "status" in result
|
||||
|
||||
async def test_check_worker_returns_dict(self, client: AsyncClient):
|
||||
"""check_worker returns a dict with status field."""
|
||||
result = await check_worker()
|
||||
assert isinstance(result, dict)
|
||||
assert "status" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMetricsEndpoint:
|
||||
"""AC3-4: Prometheus metrics endpoint (admin-only)."""
|
||||
|
||||
async def test_metrics_admin_returns_200_text_plain(self, client: AsyncClient, db_session):
|
||||
"""AC3: GET /api/v1/metrics → 200 + text/plain Prometheus format (admin only)."""
|
||||
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/metrics")
|
||||
assert resp.status_code == 200
|
||||
assert "text/plain" in resp.headers.get("content-type", "")
|
||||
|
||||
async def test_metrics_non_admin_returns_403(self, client: AsyncClient, db_session):
|
||||
"""AC3: GET /api/v1/metrics → 403 for non-admin users."""
|
||||
from tests.conftest import login_client, seed_tenant_and_users
|
||||
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "viewer@tenanta.com")
|
||||
resp = await client.get("/api/v1/metrics")
|
||||
assert resp.status_code == 403
|
||||
|
||||
async def test_metrics_unauthenticated_returns_401(self, client: AsyncClient):
|
||||
"""Metrics endpoint requires authentication — 401 without auth."""
|
||||
resp = await client.get("/api/v1/metrics")
|
||||
assert resp.status_code == 401
|
||||
|
||||
async def test_metrics_include_required_metric_names(self, client: AsyncClient, db_session):
|
||||
"""AC4: Prometheus metrics include leocrm_http_requests_total, leocrm_db_pool_connections,
|
||||
leocrm_arq_jobs_total."""
|
||||
from tests.conftest import login_client, seed_tenant_and_users
|
||||
|
||||
await seed_tenant_and_users(db_session)
|
||||
await login_client(client, "admin@tenanta.com")
|
||||
# Make a request first to generate some metrics
|
||||
await client.get("/api/v1/health")
|
||||
resp = await client.get("/api/v1/metrics")
|
||||
assert resp.status_code == 200
|
||||
text = resp.text
|
||||
assert "leocrm_http_requests_total" in text
|
||||
assert "leocrm_db_pool_connections" in text
|
||||
assert "leocrm_arq_jobs_total" in text
|
||||
|
||||
def test_generate_metrics_returns_bytes(self):
|
||||
"""generate_metrics() returns bytes in Prometheus format."""
|
||||
data = generate_metrics()
|
||||
assert isinstance(data, bytes)
|
||||
text = data.decode("utf-8")
|
||||
assert "leocrm_" in text
|
||||
|
||||
|
||||
class TestStructuredLogging:
|
||||
"""AC5-6: Structured JSON logging and error logging."""
|
||||
|
||||
def test_record_request_increments_counter(self):
|
||||
"""AC5: record_request increments http_requests_total counter and logs JSON entry."""
|
||||
with patch("app.core.monitoring.logger") as mock_logger:
|
||||
record_request(
|
||||
method="GET",
|
||||
path="/api/v1/health",
|
||||
status_code=200,
|
||||
duration_ms=12.5,
|
||||
tenant_id="test-tenant-id",
|
||||
)
|
||||
mock_logger.info.assert_called_once()
|
||||
call_args = mock_logger.info.call_args
|
||||
assert call_args[1]["method"] == "GET"
|
||||
assert call_args[1]["path"] == "/api/v1/health"
|
||||
assert call_args[1]["status"] == 200
|
||||
assert call_args[1]["duration_ms"] == 12.5
|
||||
assert call_args[1]["tenant_id"] == "test-tenant-id"
|
||||
|
||||
def test_record_request_log_has_required_fields(self):
|
||||
"""AC5: Structured JSON log entry has: timestamp, level, event, method, path, status,
|
||||
duration_ms, tenant_id."""
|
||||
with patch("app.core.monitoring.logger") as mock_logger:
|
||||
record_request(
|
||||
method="POST",
|
||||
path="/api/v1/contacts",
|
||||
status_code=201,
|
||||
duration_ms=45.3,
|
||||
tenant_id="tenant-123",
|
||||
)
|
||||
call_args = mock_logger.info.call_args
|
||||
logged_data = call_args[1]
|
||||
# event is passed as positional arg by structlog
|
||||
assert call_args[0][0] == "api_request"
|
||||
# Check all required fields are present in kwargs
|
||||
logged_data = call_args[1]
|
||||
assert "method" in logged_data
|
||||
assert "path" in logged_data
|
||||
assert "status" in logged_data
|
||||
assert "duration_ms" in logged_data
|
||||
assert "tenant_id" in logged_data
|
||||
|
||||
def test_record_error_logs_stacktrace_and_context(self):
|
||||
"""AC6: Error log includes stacktrace and request context."""
|
||||
with patch("app.core.monitoring.logger") as mock_logger:
|
||||
tb = "Traceback (most recent call last):\n File 'test.py', line 1\n raise ValueError('test')\n"
|
||||
record_error(
|
||||
event="unhandled_exception",
|
||||
method="GET",
|
||||
path="/api/v1/contacts/123",
|
||||
status_code=500,
|
||||
error="ValueError: test error",
|
||||
traceback_str=tb,
|
||||
tenant_id="tenant-456",
|
||||
)
|
||||
mock_logger.error.assert_called_once()
|
||||
call_args = mock_logger.error.call_args
|
||||
logged_data = call_args[1]
|
||||
# event is passed as keyword arg in structlog
|
||||
assert call_args[1]["event"] == "unhandled_exception"
|
||||
assert logged_data["error"] == "ValueError: test error"
|
||||
assert logged_data["traceback"] == tb
|
||||
assert logged_data["method"] == "GET"
|
||||
assert logged_data["path"] == "/api/v1/contacts/123"
|
||||
assert logged_data["status"] == 500
|
||||
assert logged_data["tenant_id"] == "tenant-456"
|
||||
|
||||
def test_record_error_without_traceback(self):
|
||||
"""Error logging works even without traceback."""
|
||||
with patch("app.core.monitoring.logger") as mock_logger:
|
||||
record_error(
|
||||
event="db_error",
|
||||
method="GET",
|
||||
path="/api/v1/health",
|
||||
status_code=503,
|
||||
error="Connection refused",
|
||||
)
|
||||
mock_logger.error.assert_called_once()
|
||||
call_args = mock_logger.error.call_args
|
||||
assert call_args[1]["error"] == "Connection refused"
|
||||
assert call_args[1]["traceback"] is None
|
||||
|
||||
def test_record_arq_job_increments_counter(self):
|
||||
"""record_arq_job increments the arq_jobs_total counter."""
|
||||
with patch("app.core.monitoring.arq_jobs_total") as mock_counter:
|
||||
record_arq_job("export_contacts", "completed")
|
||||
mock_counter.labels.assert_called_once_with(
|
||||
function="export_contacts", status="completed"
|
||||
)
|
||||
@@ -0,0 +1,262 @@
|
||||
"""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}"
|
||||
Reference in New Issue
Block a user