| """ |
| Test suite for chart endpoints |
| Validates rate limit history and freshness history endpoints |
| """ |
|
|
| import pytest |
| import requests as R |
| from datetime import datetime, timedelta |
|
|
| |
| BASE = "http://localhost:7860" |
|
|
|
|
| class TestRateLimitHistory: |
| """Test suite for /api/charts/rate-limit-history endpoint""" |
|
|
| def test_rate_limit_default(self): |
| """Test rate limit history with default parameters""" |
| r = R.get(f"{BASE}/api/charts/rate-limit-history") |
| r.raise_for_status() |
| data = r.json() |
|
|
| |
| assert isinstance(data, list), "Response should be a list" |
|
|
| if len(data) > 0: |
| |
| s = data[0] |
| assert "provider" in s, "Series should have provider field" |
| assert "hours" in s, "Series should have hours field" |
| assert "series" in s, "Series should have series field" |
| assert "meta" in s, "Series should have meta field" |
|
|
| |
| assert s["hours"] == 24, "Default hours should be 24" |
|
|
| |
| assert isinstance(s["series"], list), "series should be a list" |
| assert len(s["series"]) == 24, "Should have 24 data points for 24 hours" |
|
|
| |
| for point in s["series"]: |
| assert "t" in point, "Point should have timestamp (t)" |
| assert "pct" in point, "Point should have percentage (pct)" |
| assert 0 <= point["pct"] <= 100, f"Percentage should be 0-100, got {point['pct']}" |
|
|
| |
| try: |
| datetime.fromisoformat(point["t"].replace('Z', '+00:00')) |
| except ValueError: |
| pytest.fail(f"Invalid timestamp format: {point['t']}") |
|
|
| |
| meta = s["meta"] |
| assert "limit_type" in meta, "Meta should have limit_type" |
| assert "limit_value" in meta, "Meta should have limit_value" |
|
|
| def test_rate_limit_48h_subset(self): |
| """Test rate limit history with custom time range and provider selection""" |
| r = R.get( |
| f"{BASE}/api/charts/rate-limit-history", |
| params={"hours": 48, "providers": "coingecko,cmc"} |
| ) |
| r.raise_for_status() |
| data = r.json() |
|
|
| assert isinstance(data, list), "Response should be a list" |
| assert len(data) <= 2, "Should have at most 2 providers (coingecko, cmc)" |
|
|
| for series in data: |
| assert series["hours"] == 48, "Should have 48 hours of data" |
| assert len(series["series"]) == 48, "Should have 48 data points" |
| assert series["provider"] in ["coingecko", "cmc"], "Provider should match requested" |
|
|
| def test_rate_limit_hours_clamping(self): |
| """Test that hours parameter is properly clamped to valid range""" |
| |
| r = R.get(f"{BASE}/api/charts/rate-limit-history", params={"hours": 0}) |
| assert r.status_code in [200, 422], "Should handle hours=0" |
|
|
| |
| r = R.get(f"{BASE}/api/charts/rate-limit-history", params={"hours": 999}) |
| assert r.status_code in [200, 422], "Should handle hours=999" |
|
|
| def test_rate_limit_invalid_provider(self): |
| """Test rejection of invalid provider names""" |
| r = R.get( |
| f"{BASE}/api/charts/rate-limit-history", |
| params={"providers": "invalid_provider_xyz"} |
| ) |
|
|
| |
| assert r.status_code in [400, 404], "Should reject invalid provider names" |
|
|
| def test_rate_limit_max_providers(self): |
| """Test that provider list is limited to max 5""" |
| |
| providers_list = ",".join([f"provider{i}" for i in range(10)]) |
| r = R.get( |
| f"{BASE}/api/charts/rate-limit-history", |
| params={"providers": providers_list} |
| ) |
|
|
| |
| if r.status_code == 200: |
| data = r.json() |
| assert len(data) <= 5, "Should limit to max 5 providers" |
|
|
| def test_rate_limit_response_time(self): |
| """Test that endpoint responds within performance target (< 200ms for 24h)""" |
| import time |
| start = time.time() |
| r = R.get(f"{BASE}/api/charts/rate-limit-history") |
| duration_ms = (time.time() - start) * 1000 |
|
|
| r.raise_for_status() |
| |
| assert duration_ms < 500, f"Response took {duration_ms:.0f}ms (target < 500ms)" |
|
|
|
|
| class TestFreshnessHistory: |
| """Test suite for /api/charts/freshness-history endpoint""" |
|
|
| def test_freshness_default(self): |
| """Test freshness history with default parameters""" |
| r = R.get(f"{BASE}/api/charts/freshness-history") |
| r.raise_for_status() |
| data = r.json() |
|
|
| |
| assert isinstance(data, list), "Response should be a list" |
|
|
| if len(data) > 0: |
| |
| s = data[0] |
| assert "provider" in s, "Series should have provider field" |
| assert "hours" in s, "Series should have hours field" |
| assert "series" in s, "Series should have series field" |
| assert "meta" in s, "Series should have meta field" |
|
|
| |
| assert s["hours"] == 24, "Default hours should be 24" |
|
|
| |
| assert isinstance(s["series"], list), "series should be a list" |
| assert len(s["series"]) == 24, "Should have 24 data points for 24 hours" |
|
|
| |
| for point in s["series"]: |
| assert "t" in point, "Point should have timestamp (t)" |
| assert "staleness_min" in point, "Point should have staleness_min" |
| assert "ttl_min" in point, "Point should have ttl_min" |
| assert "status" in point, "Point should have status" |
|
|
| assert point["staleness_min"] >= 0, "Staleness should be non-negative" |
| assert point["ttl_min"] > 0, "TTL should be positive" |
| assert point["status"] in ["fresh", "aging", "stale"], f"Invalid status: {point['status']}" |
|
|
| |
| try: |
| datetime.fromisoformat(point["t"].replace('Z', '+00:00')) |
| except ValueError: |
| pytest.fail(f"Invalid timestamp format: {point['t']}") |
|
|
| |
| meta = s["meta"] |
| assert "category" in meta, "Meta should have category" |
| assert "default_ttl" in meta, "Meta should have default_ttl" |
|
|
| def test_freshness_72h_subset(self): |
| """Test freshness history with custom time range and provider selection""" |
| r = R.get( |
| f"{BASE}/api/charts/freshness-history", |
| params={"hours": 72, "providers": "coingecko,binance"} |
| ) |
| r.raise_for_status() |
| data = r.json() |
|
|
| assert isinstance(data, list), "Response should be a list" |
| assert len(data) <= 2, "Should have at most 2 providers" |
|
|
| for series in data: |
| assert series["hours"] == 72, "Should have 72 hours of data" |
| assert len(series["series"]) == 72, "Should have 72 data points" |
| assert series["provider"] in ["coingecko", "binance"], "Provider should match requested" |
|
|
| def test_freshness_hours_clamping(self): |
| """Test that hours parameter is properly clamped to valid range""" |
| |
| r = R.get(f"{BASE}/api/charts/freshness-history", params={"hours": 0}) |
| assert r.status_code in [200, 422], "Should handle hours=0" |
|
|
| |
| r = R.get(f"{BASE}/api/charts/freshness-history", params={"hours": 999}) |
| assert r.status_code in [200, 422], "Should handle hours=999" |
|
|
| def test_freshness_invalid_provider(self): |
| """Test rejection of invalid provider names""" |
| r = R.get( |
| f"{BASE}/api/charts/freshness-history", |
| params={"providers": "foo,bar"} |
| ) |
|
|
| |
| assert r.status_code in [400, 404], "Should reject invalid provider names" |
|
|
| def test_freshness_status_derivation(self): |
| """Test that status is correctly derived from staleness and TTL""" |
| r = R.get(f"{BASE}/api/charts/freshness-history") |
| r.raise_for_status() |
| data = r.json() |
|
|
| if len(data) > 0: |
| for series in data: |
| ttl = series["meta"]["default_ttl"] |
|
|
| for point in series["series"]: |
| staleness = point["staleness_min"] |
| status = point["status"] |
|
|
| |
| if staleness <= ttl: |
| expected = "fresh" |
| elif staleness <= ttl * 2: |
| expected = "aging" |
| else: |
| expected = "stale" |
|
|
| |
| if staleness == 999.0: |
| assert status == "stale", "No data should be marked as stale" |
| else: |
| assert status == expected, f"Status mismatch: staleness={staleness}, ttl={ttl}, expected={expected}, got={status}" |
|
|
| def test_freshness_response_time(self): |
| """Test that endpoint responds within performance target (< 200ms for 24h)""" |
| import time |
| start = time.time() |
| r = R.get(f"{BASE}/api/charts/freshness-history") |
| duration_ms = (time.time() - start) * 1000 |
|
|
| r.raise_for_status() |
| |
| assert duration_ms < 500, f"Response took {duration_ms:.0f}ms (target < 500ms)" |
|
|
|
|
| class TestSecurityValidation: |
| """Test security and validation measures""" |
|
|
| def test_sql_injection_prevention(self): |
| """Test that SQL injection attempts are safely handled""" |
| malicious_providers = "'; DROP TABLE providers; --" |
| r = R.get( |
| f"{BASE}/api/charts/rate-limit-history", |
| params={"providers": malicious_providers} |
| ) |
|
|
| |
| assert r.status_code in [400, 404, 500], "Should reject SQL injection attempts" |
|
|
| def test_xss_prevention(self): |
| """Test that XSS attempts are safely handled""" |
| malicious_providers = "<script>alert('xss')</script>" |
| r = R.get( |
| f"{BASE}/api/charts/rate-limit-history", |
| params={"providers": malicious_providers} |
| ) |
|
|
| |
| assert r.status_code in [400, 404], "Should reject XSS attempts" |
|
|
| def test_parameter_type_validation(self): |
| """Test that invalid parameter types are rejected""" |
| |
| r = R.get( |
| f"{BASE}/api/charts/rate-limit-history", |
| params={"hours": "invalid"} |
| ) |
| assert r.status_code == 422, "Should reject invalid parameter type" |
|
|
|
|
| class TestEdgeCases: |
| """Test edge cases and boundary conditions""" |
|
|
| def test_empty_provider_list(self): |
| """Test behavior with empty provider list""" |
| r = R.get( |
| f"{BASE}/api/charts/rate-limit-history", |
| params={"providers": ""} |
| ) |
| r.raise_for_status() |
| data = r.json() |
|
|
| |
| assert isinstance(data, list), "Should return list even with empty providers param" |
|
|
| def test_whitespace_handling(self): |
| """Test that whitespace in provider names is properly handled""" |
| r = R.get( |
| f"{BASE}/api/charts/rate-limit-history", |
| params={"providers": " coingecko , cmc "} |
| ) |
|
|
| |
| if r.status_code == 200: |
| data = r.json() |
| for series in data: |
| assert series["provider"].strip() == series["provider"], "Provider names should be trimmed" |
|
|
| def test_concurrent_requests(self): |
| """Test that endpoint handles concurrent requests safely""" |
| import concurrent.futures |
|
|
| def make_request(): |
| r = R.get(f"{BASE}/api/charts/rate-limit-history") |
| r.raise_for_status() |
| return r.json() |
|
|
| |
| with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: |
| futures = [executor.submit(make_request) for _ in range(5)] |
| results = [f.result() for f in concurrent.futures.as_completed(futures)] |
|
|
| |
| assert len(results) == 5, "All concurrent requests should succeed" |
|
|
|
|
| if __name__ == "__main__": |
| pytest.main([__file__, "-v", "--tb=short"]) |
|
|