InnSight-Backend / api /tests /test_performance.py
jackonthemike's picture
Initial commit: InnSight scraper backend with Playwright
d77abf8
"""
Performance and Latency Tests
==============================
Tests for p95 latency thresholds, concurrent request handling, and SLA compliance.
"""
import pytest
import time
import statistics
import concurrent.futures
from typing import List
# Performance thresholds (in seconds)
THRESHOLDS = {
"comparisons_p95": 0.5, # 500ms for comparison reads
"dashboard_p95": 0.3, # 300ms for dashboard
"export_p95": 2.0, # 2000ms for exports
"search_p95": 1.0, # 1000ms for hotel search
"auth_p95": 0.2, # 200ms for auth operations
}
# Number of iterations for latency tests
LATENCY_ITERATIONS = 20
def calculate_p95(latencies: List[float]) -> float:
"""Calculate p95 latency from a list of latencies"""
if not latencies:
return 0.0
sorted_latencies = sorted(latencies)
index = int(len(sorted_latencies) * 0.95)
return sorted_latencies[min(index, len(sorted_latencies) - 1)]
class TestLatencyThresholds:
"""Tests for API endpoint latency SLAs"""
@pytest.mark.performance
def test_comparisons_endpoint_p95_latency(self, client, auth_headers):
"""Test that comparisons endpoint meets p95 latency SLA"""
latencies = []
for _ in range(LATENCY_ITERATIONS):
start = time.perf_counter()
response = client.get("/api/comparisons", headers=auth_headers)
end = time.perf_counter()
if response.status_code in [200, 404]:
latencies.append(end - start)
if latencies:
p95 = calculate_p95(latencies)
avg = statistics.mean(latencies)
print(f"Comparisons - p95: {p95*1000:.1f}ms, avg: {avg*1000:.1f}ms")
assert p95 < THRESHOLDS["comparisons_p95"], f"p95 latency {p95:.3f}s exceeds threshold"
@pytest.mark.performance
def test_dashboard_endpoint_p95_latency(self, client, auth_headers):
"""Test that dashboard endpoint meets p95 latency SLA"""
latencies = []
for _ in range(LATENCY_ITERATIONS):
start = time.perf_counter()
response = client.get("/api/dashboard", headers=auth_headers)
end = time.perf_counter()
if response.status_code in [200, 403, 404]:
latencies.append(end - start)
if latencies:
p95 = calculate_p95(latencies)
print(f"Dashboard - p95: {p95*1000:.1f}ms")
assert p95 < THRESHOLDS["dashboard_p95"], f"p95 latency {p95:.3f}s exceeds threshold"
@pytest.mark.performance
def test_export_endpoint_p95_latency(self, client, auth_headers):
"""Test that export endpoint meets p95 latency SLA"""
latencies = []
export_data = {
"format": "excel",
"data": [{"hotel": f"Hotel {i}", "price": 100 + i} for i in range(10)]
}
for _ in range(10): # Fewer iterations for expensive operation
start = time.perf_counter()
response = client.post("/api/export", json=export_data, headers=auth_headers)
end = time.perf_counter()
if response.status_code in [200, 400, 422]:
latencies.append(end - start)
if latencies:
p95 = calculate_p95(latencies)
print(f"Export - p95: {p95*1000:.1f}ms")
assert p95 < THRESHOLDS["export_p95"], f"p95 latency {p95:.3f}s exceeds threshold"
@pytest.mark.performance
def test_auth_endpoint_p95_latency(self, client):
"""Test that auth endpoints meet p95 latency SLA"""
latencies = []
for _ in range(LATENCY_ITERATIONS):
start = time.perf_counter()
response = client.get("/api/health") # Lightweight auth check
end = time.perf_counter()
if response.status_code == 200:
latencies.append(end - start)
if latencies:
p95 = calculate_p95(latencies)
print(f"Health/Auth - p95: {p95*1000:.1f}ms")
assert p95 < THRESHOLDS["auth_p95"], f"p95 latency {p95:.3f}s exceeds threshold"
class TestConcurrentRequests:
"""Tests for concurrent request handling"""
@pytest.mark.performance
def test_concurrent_reads(self, client, auth_headers):
"""Test that API handles concurrent read requests"""
num_concurrent = 10
def make_request():
start = time.perf_counter()
response = client.get("/api/dashboard", headers=auth_headers)
end = time.perf_counter()
return response.status_code, end - start
with concurrent.futures.ThreadPoolExecutor(max_workers=num_concurrent) as executor:
futures = [executor.submit(make_request) for _ in range(num_concurrent)]
results = [f.result() for f in futures]
# All requests should succeed or return consistent status
status_codes = [r[0] for r in results]
latencies = [r[1] for r in results]
# Should not have server errors
assert 500 not in status_codes
# Average latency should still be reasonable
avg_latency = statistics.mean(latencies)
print(f"Concurrent reads - avg latency: {avg_latency*1000:.1f}ms")
assert avg_latency < 2.0, "Concurrent request latency too high"
@pytest.mark.performance
def test_concurrent_writes(self, client, auth_headers):
"""Test that API handles concurrent write requests"""
num_concurrent = 5 # Fewer concurrent writes
def make_request(i):
start = time.perf_counter()
response = client.post("/api/export", json={
"format": "excel",
"data": [{"hotel": f"Test {i}", "price": 100}]
}, headers=auth_headers)
end = time.perf_counter()
return response.status_code, end - start
with concurrent.futures.ThreadPoolExecutor(max_workers=num_concurrent) as executor:
futures = [executor.submit(make_request, i) for i in range(num_concurrent)]
results = [f.result() for f in futures]
status_codes = [r[0] for r in results]
# Should not crash
assert 500 not in status_codes
@pytest.mark.performance
def test_connection_pool_under_load(self, client, auth_headers):
"""Test that connection pooling handles load"""
num_requests = 50
successful = 0
for _ in range(num_requests):
response = client.get("/api/health")
if response.status_code == 200:
successful += 1
success_rate = successful / num_requests
print(f"Connection pool - success rate: {success_rate*100:.1f}%")
assert success_rate >= 0.95, "Success rate too low under load"
class TestScraperPerformance:
"""Tests for scraper job performance"""
@pytest.mark.performance
def test_scrape_request_timeout(self, client, auth_headers):
"""Test that scrape requests have appropriate timeouts"""
start = time.perf_counter()
response = client.post("/api/scrape", json={
"url": "https://example.com/slow-page"
}, headers=auth_headers, timeout=30)
end = time.perf_counter()
elapsed = end - start
# Should not take longer than timeout
assert elapsed < 30, "Scrape request exceeded timeout"
# Should return quickly with validation error or queued status
if response.status_code != 404:
assert elapsed < 5, "Scrape validation should be fast"
@pytest.mark.performance
def test_scraper_retry_backoff(self, client, auth_headers):
"""Test that scraper implements proper retry backoff"""
# This is more of an integration test
# Verifies that retries don't overwhelm the system
response = client.post("/api/scrape", json={
"url": "https://httpstat.us/503" # Will return 503
}, headers=auth_headers)
# Should handle gracefully
assert response.status_code != 500
class TestDatabasePerformance:
"""Tests for database query performance"""
@pytest.mark.performance
def test_user_lookup_performance(self, client, auth_headers):
"""Test that user lookup is fast"""
latencies = []
for _ in range(10):
start = time.perf_counter()
response = client.get("/api/auth/me", headers=auth_headers)
end = time.perf_counter()
if response.status_code == 200:
latencies.append(end - start)
if latencies:
avg = statistics.mean(latencies)
print(f"User lookup - avg: {avg*1000:.1f}ms")
assert avg < 0.1, "User lookup too slow"
@pytest.mark.performance
def test_hotel_list_performance(self, client, auth_headers):
"""Test that hotel listing is performant"""
latencies = []
for _ in range(10):
start = time.perf_counter()
response = client.get("/api/hotels", headers=auth_headers)
end = time.perf_counter()
if response.status_code in [200, 404]:
latencies.append(end - start)
if latencies:
avg = statistics.mean(latencies)
print(f"Hotel list - avg: {avg*1000:.1f}ms")
assert avg < 0.5, "Hotel listing too slow"
class TestResourceUsage:
"""Tests for resource usage patterns"""
@pytest.mark.performance
def test_memory_stable_under_load(self, client, auth_headers):
"""Test that memory usage is stable under repeated requests"""
import gc
# Force garbage collection before test
gc.collect()
# Make many requests
for i in range(100):
client.get("/api/health")
if i % 20 == 0:
gc.collect()
# Should complete without memory issues
# (Python's GC should handle this)
@pytest.mark.performance
def test_no_connection_leaks(self, client, auth_headers):
"""Test that connections are properly closed"""
# Make requests in a loop
for _ in range(50):
response = client.get("/api/dashboard", headers=auth_headers)
# Connection should be reused or properly closed
# Final request should still work
response = client.get("/api/health")
assert response.status_code == 200
# Custom pytest markers
def pytest_configure(config):
config.addinivalue_line(
"markers", "performance: mark test as performance test"
)
config.addinivalue_line(
"markers", "slow: mark test as slow running"
)