feat: merge cartsnitch/api into api/ subdirectory

Consolidate API gateway service into monorepo.
Squashed from https://github.com/cartsnitch/api main (89bacb1).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Coupon Carl
2026-03-28 02:24:02 +00:00
commit b7e6f637a7
91 changed files with 6296 additions and 0 deletions
View File
+19
View File
@@ -0,0 +1,19 @@
"""Conftest for middleware tests — re-enables rate limiting after global disable."""
import pytest
from cartsnitch_api.config import settings as cartsnitch_settings
@pytest.fixture(autouse=True)
def enable_rate_limiting():
"""Re-enable rate limiting after the global disable_rate_limiting fixture runs.
The root conftest disables rate limiting for all tests to prevent 429
interference. Middleware tests need it active to verify headers and
enforcement. This fixture runs after the root fixture (more local = later
in setup order) so True is the effective value during the test body.
"""
cartsnitch_settings.rate_limit_enabled = True
yield
cartsnitch_settings.rate_limit_enabled = False
@@ -0,0 +1,54 @@
"""Tests for structured error responses and error monitoring."""
import pytest
@pytest.mark.asyncio
async def test_404_returns_structured_error(client):
"""Non-existent route should return structured error."""
resp = await client.get("/nonexistent")
assert resp.status_code == 404
body = resp.json()
assert "detail" in body
assert "code" in body
assert body["code"] == "NOT_FOUND"
@pytest.mark.asyncio
async def test_validation_error_returns_422_with_field_errors(client):
"""Invalid request body should return structured validation errors."""
resp = await client.post(
"/auth/register",
json={"email": "not-an-email", "password": "short", "display_name": ""},
)
assert resp.status_code == 422
body = resp.json()
assert body["code"] == "VALIDATION_ERROR"
assert "errors" in body
assert isinstance(body["errors"], list)
assert len(body["errors"]) > 0
# Each error should have field, message, type
for err in body["errors"]:
assert "field" in err
assert "message" in err
assert "type" in err
@pytest.mark.asyncio
async def test_error_stats_requires_service_key(client):
"""Error stats endpoint should require X-Service-Key."""
resp = await client.get("/internal/error-stats")
assert resp.status_code == 422 # Missing required header
@pytest.mark.asyncio
async def test_error_stats_with_valid_key(client):
"""Error stats endpoint returns monitoring data with valid key."""
resp = await client.get(
"/internal/error-stats",
headers={"X-Service-Key": "change-me-in-production"},
)
assert resp.status_code == 200
body = resp.json()
assert "error_counts" in body
assert "recent_5xx_count" in body
+55
View File
@@ -0,0 +1,55 @@
"""Tests for rate limiting middleware."""
import pytest
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter
class TestSlidingWindowCounter:
def test_allows_within_limit(self):
counter = _SlidingWindowCounter(max_requests=5, window_seconds=60)
for i in range(5):
allowed, remaining, retry = counter.is_allowed("test-key")
assert allowed is True
assert remaining == 4 - i
def test_blocks_over_limit(self):
counter = _SlidingWindowCounter(max_requests=3, window_seconds=60)
for _ in range(3):
counter.is_allowed("test-key")
allowed, remaining, retry = counter.is_allowed("test-key")
assert allowed is False
assert remaining == 0
assert retry > 0
def test_separate_keys(self):
counter = _SlidingWindowCounter(max_requests=2, window_seconds=60)
# Fill key-a
counter.is_allowed("key-a")
counter.is_allowed("key-a")
allowed_a, _, _ = counter.is_allowed("key-a")
assert allowed_a is False
# key-b should still be allowed
allowed_b, remaining, _ = counter.is_allowed("key-b")
assert allowed_b is True
assert remaining == 1
@pytest.mark.asyncio
async def test_rate_limit_returns_429(client):
"""Public endpoint should return 429 after limit exceeded."""
# The default limit is 60/min — we won't hit it in normal tests,
# but we verify the middleware adds rate limit headers.
resp = await client.get("/public/inflation")
assert "x-ratelimit-limit" in resp.headers
assert "x-ratelimit-remaining" in resp.headers
@pytest.mark.asyncio
async def test_health_skips_rate_limit(client):
"""Health endpoint should not have rate limit headers."""
resp = await client.get("/health")
assert resp.status_code == 200
assert "x-ratelimit-limit" not in resp.headers