Skip to content

Commit b87b4ef

Browse files
committed
test(api): add rate limit and admin e2e tests, fix aggregate date query
- Add tests/test_rate_limit.py covering InMemoryRateLimiter, config registry, middleware skip behavior, decorator compatibility - Add tests/e2e/test_admin_flow.py with 7 E2E tests for cleanup, inactive relations, wellness aggregate, and auth - Fix aggregate_wellness date comparison: parse ISO string to datetime.date for asyncpg compatibility - Add apps/worker/.env.example documenting worker config
1 parent 4e488a6 commit b87b4ef

4 files changed

Lines changed: 384 additions & 1 deletion

File tree

apps/api/src/admin/repository.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Admin-specific database queries."""
22

3+
import datetime as dt
34
from datetime import datetime
45
from typing import Any
56
from uuid import UUID
@@ -85,7 +86,7 @@ async def aggregate_wellness(
8586
select(WellnessLog.status, func.count().label("count"))
8687
.where(
8788
WellnessLog.host_id == host_id,
88-
func.date(WellnessLog.created_at) == date,
89+
func.date(WellnessLog.created_at) == dt.date.fromisoformat(date),
8990
)
9091
.group_by(WellnessLog.status)
9192
)
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
"""E2E: Admin endpoints — cleanup, inactive relations, wellness aggregate."""
2+
3+
from datetime import UTC, datetime, timedelta
4+
from unittest.mock import patch
5+
6+
import pytest
7+
from httpx import AsyncClient
8+
from sqlalchemy.ext.asyncio import AsyncSession
9+
10+
from src.notifications.model import DeviceToken
11+
from src.relations.model import CareRelation
12+
from src.users.model import User
13+
from src.wellness.model import WellnessLog
14+
from tests.e2e.conftest import CAREGIVER_USER_ID, HOST_USER_ID
15+
16+
pytestmark = [
17+
pytest.mark.filterwarnings("ignore::jwt.warnings.InsecureKeyLengthWarning"),
18+
]
19+
20+
21+
# ── Seed helpers ──────────────────────────────────────────────────
22+
async def _seed_host_and_caregiver(db: AsyncSession) -> tuple[User, User]:
23+
"""Insert host + caregiver users."""
24+
host = User(
25+
id=HOST_USER_ID,
26+
email="host-admin@test.com",
27+
name="Admin Host",
28+
role="host",
29+
provider="google",
30+
provider_id="google-admin-host",
31+
email_verified=True,
32+
)
33+
caregiver = User(
34+
id=CAREGIVER_USER_ID,
35+
email="cg-admin@test.com",
36+
name="Admin Caregiver",
37+
role="concierge",
38+
provider="google",
39+
provider_id="google-admin-cg",
40+
email_verified=True,
41+
)
42+
db.add_all([host, caregiver])
43+
await db.commit()
44+
return host, caregiver
45+
46+
47+
# ── Tests ─────────────────────────────────────────────────────────
48+
49+
50+
class TestAdminCleanup:
51+
async def test_cleanup_deletes_old_wellness_logs(
52+
self, client: AsyncClient, db_session: AsyncSession
53+
) -> None:
54+
host, _ = await _seed_host_and_caregiver(db_session)
55+
56+
# Insert old wellness log (120 days ago)
57+
old_log = WellnessLog(
58+
host_id=host.id,
59+
status="normal",
60+
summary="Old log",
61+
details={},
62+
created_at=datetime.now(UTC) - timedelta(days=120),
63+
)
64+
# Insert recent wellness log
65+
new_log = WellnessLog(
66+
host_id=host.id,
67+
status="normal",
68+
summary="Recent log",
69+
details={},
70+
)
71+
db_session.add_all([old_log, new_log])
72+
await db_session.commit()
73+
74+
resp = await client.post(
75+
"/api/v1/admin/cleanup",
76+
json={"retention_days": 90, "resource_type": "wellness_logs"},
77+
)
78+
assert resp.status_code == 200
79+
data = resp.json()
80+
assert data["deleted_wellness_logs"] == 1
81+
assert data["deactivated_tokens"] == 0
82+
83+
async def test_cleanup_deactivates_old_tokens(
84+
self, client: AsyncClient, db_session: AsyncSession
85+
) -> None:
86+
host, _ = await _seed_host_and_caregiver(db_session)
87+
88+
# Insert old device token
89+
old_token = DeviceToken(
90+
user_id=host.id,
91+
token="old-fcm-token-001", # noqa: S106
92+
platform="android",
93+
is_active=True,
94+
updated_at=datetime.now(UTC) - timedelta(days=100),
95+
)
96+
db_session.add(old_token)
97+
await db_session.commit()
98+
99+
resp = await client.post(
100+
"/api/v1/admin/cleanup",
101+
json={"retention_days": 90, "resource_type": "device_tokens"},
102+
)
103+
assert resp.status_code == 200
104+
data = resp.json()
105+
assert data["deactivated_tokens"] == 1
106+
107+
108+
class TestAdminInactiveRelations:
109+
async def test_list_inactive_relations(
110+
self, client: AsyncClient, db_session: AsyncSession
111+
) -> None:
112+
host, caregiver = await _seed_host_and_caregiver(db_session)
113+
114+
# Create active relation with no wellness logs → should be inactive
115+
relation = CareRelation(
116+
host_id=host.id,
117+
caregiver_id=caregiver.id,
118+
role="concierge",
119+
is_active=True,
120+
)
121+
db_session.add(relation)
122+
await db_session.commit()
123+
124+
resp = await client.get(
125+
"/api/v1/admin/inactive-relations",
126+
params={"threshold_days": 7},
127+
)
128+
assert resp.status_code == 200
129+
data = resp.json()
130+
assert len(data) >= 1
131+
found = [r for r in data if r["host_id"] == str(host.id)]
132+
assert len(found) == 1
133+
assert found[0]["role"] == "concierge"
134+
135+
async def test_active_relation_with_recent_log_excluded(
136+
self, client: AsyncClient, db_session: AsyncSession
137+
) -> None:
138+
host, caregiver = await _seed_host_and_caregiver(db_session)
139+
140+
relation = CareRelation(
141+
host_id=host.id,
142+
caregiver_id=caregiver.id,
143+
role="concierge",
144+
is_active=True,
145+
)
146+
recent_log = WellnessLog(
147+
host_id=host.id,
148+
status="normal",
149+
summary="Just now",
150+
details={},
151+
)
152+
db_session.add_all([relation, recent_log])
153+
await db_session.commit()
154+
155+
resp = await client.get(
156+
"/api/v1/admin/inactive-relations",
157+
params={"threshold_days": 7},
158+
)
159+
assert resp.status_code == 200
160+
data = resp.json()
161+
# Host with recent log should NOT appear
162+
found = [r for r in data if r["host_id"] == str(host.id)]
163+
assert len(found) == 0
164+
165+
166+
class TestAdminWellnessAggregate:
167+
async def test_aggregate_returns_counts_by_status(
168+
self, client: AsyncClient, db_session: AsyncSession
169+
) -> None:
170+
host, _ = await _seed_host_and_caregiver(db_session)
171+
today = datetime.now(UTC).strftime("%Y-%m-%d")
172+
173+
logs = [
174+
WellnessLog(host_id=host.id, status="normal", summary="ok", details={}),
175+
WellnessLog(host_id=host.id, status="normal", summary="ok2", details={}),
176+
WellnessLog(host_id=host.id, status="warning", summary="hmm", details={}),
177+
]
178+
db_session.add_all(logs)
179+
await db_session.commit()
180+
181+
resp = await client.get(
182+
"/api/v1/admin/wellness/aggregate",
183+
params={"host_id": str(host.id), "date": today},
184+
)
185+
assert resp.status_code == 200
186+
data = resp.json()
187+
assert data["total_logs"] == 3
188+
assert data["by_status"]["normal"] == 2
189+
assert data["by_status"]["warning"] == 1
190+
191+
async def test_aggregate_empty_date(
192+
self, client: AsyncClient, db_session: AsyncSession
193+
) -> None:
194+
host, _ = await _seed_host_and_caregiver(db_session)
195+
196+
resp = await client.get(
197+
"/api/v1/admin/wellness/aggregate",
198+
params={"host_id": str(host.id), "date": "2020-01-01"},
199+
)
200+
assert resp.status_code == 200
201+
data = resp.json()
202+
assert data["total_logs"] == 0
203+
assert data["by_status"] == {}
204+
205+
206+
class TestAdminAuth:
207+
@patch("src.lib.internal_auth.settings")
208+
async def test_missing_key_returns_401_when_configured(
209+
self, mock_settings: object, client: AsyncClient
210+
) -> None:
211+
"""When INTERNAL_API_KEY is set, missing header returns 401."""
212+
mock_settings.INTERNAL_API_KEY = "e2e-secret" # type: ignore[attr-defined]
213+
resp = await client.post(
214+
"/api/v1/admin/cleanup",
215+
json={"retention_days": 90},
216+
)
217+
assert resp.status_code == 401

apps/api/tests/test_rate_limit.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Tests for rate limiting: middleware, decorator, per-endpoint limits."""
2+
3+
from unittest.mock import AsyncMock, patch
4+
5+
import pytest
6+
from fastapi import Request
7+
from fastapi.testclient import TestClient
8+
9+
from src.admin.schemas import CleanupResponse
10+
from src.lib.rate_limit import (
11+
InMemoryRateLimiter,
12+
RateLimitConfig,
13+
default_key_func,
14+
get_rate_limiter,
15+
reset_rate_limiters,
16+
)
17+
18+
pytestmark = [
19+
pytest.mark.filterwarnings("ignore::jwt.warnings.InsecureKeyLengthWarning"),
20+
]
21+
22+
23+
# ── InMemoryRateLimiter unit tests ──────────────────────────────
24+
25+
26+
class TestInMemoryRateLimiter:
27+
def test_allows_under_limit(self) -> None:
28+
limiter = InMemoryRateLimiter(requests=5, window=60)
29+
allowed, remaining, _ = limiter.is_allowed("key1")
30+
assert allowed is True
31+
assert remaining == 4
32+
33+
def test_blocks_over_limit(self) -> None:
34+
limiter = InMemoryRateLimiter(requests=3, window=60)
35+
for _ in range(3):
36+
limiter.is_allowed("key1")
37+
allowed, remaining, _ = limiter.is_allowed("key1")
38+
assert allowed is False
39+
assert remaining == 0
40+
41+
def test_different_keys_independent(self) -> None:
42+
limiter = InMemoryRateLimiter(requests=2, window=60)
43+
for _ in range(2):
44+
limiter.is_allowed("key_a")
45+
# key_a exhausted
46+
allowed_a, _, _ = limiter.is_allowed("key_a")
47+
assert allowed_a is False
48+
# key_b still has quota
49+
allowed_b, remaining_b, _ = limiter.is_allowed("key_b")
50+
assert allowed_b is True
51+
assert remaining_b == 1
52+
53+
54+
# ── Config-keyed limiter registry ───────────────────────────────
55+
56+
57+
class TestGetRateLimiter:
58+
@patch("src.lib.rate_limit.settings")
59+
def test_returns_in_memory_when_no_redis(self, mock_settings: object) -> None:
60+
mock_settings.REDIS_URL = None # type: ignore[attr-defined]
61+
reset_rate_limiters()
62+
config = RateLimitConfig(requests=10, window=60)
63+
limiter = get_rate_limiter(config)
64+
assert isinstance(limiter, InMemoryRateLimiter)
65+
66+
def test_same_config_returns_same_instance(self) -> None:
67+
reset_rate_limiters()
68+
config_a = RateLimitConfig(requests=10, window=60)
69+
config_b = RateLimitConfig(requests=10, window=60)
70+
assert get_rate_limiter(config_a) is get_rate_limiter(config_b)
71+
72+
def test_different_config_returns_different_instance(self) -> None:
73+
reset_rate_limiters()
74+
config_a = RateLimitConfig(requests=10, window=60)
75+
config_b = RateLimitConfig(requests=20, window=60)
76+
assert get_rate_limiter(config_a) is not get_rate_limiter(config_b)
77+
78+
79+
# ── Middleware tests ────────────────────────────────────────────
80+
81+
82+
class TestRateLimitMiddleware:
83+
def test_health_endpoint_skips_rate_limit(self, client: TestClient) -> None:
84+
"""/health is skipped by middleware — no rate limit headers."""
85+
resp = client.get("/health")
86+
assert "X-RateLimit-Limit" not in resp.headers
87+
88+
def test_logout_endpoint_gets_rate_limit_headers(self, client: TestClient) -> None:
89+
"""Non-skipped endpoints should get rate limit headers."""
90+
# POST /logout doesn't need DB — safe to call in unit tests
91+
resp = client.post("/api/v1/auth/logout")
92+
assert resp.status_code == 204
93+
assert "X-RateLimit-Limit" in resp.headers
94+
assert resp.headers["X-RateLimit-Limit"] == "100"
95+
96+
@patch("src.admin.service.cleanup_data")
97+
def test_admin_endpoints_skip_rate_limit(
98+
self, mock_cleanup: AsyncMock, client: TestClient
99+
) -> None:
100+
"""Admin endpoints should bypass rate limiting."""
101+
mock_cleanup.return_value = CleanupResponse(
102+
deleted_wellness_logs=0, deactivated_tokens=0
103+
)
104+
resp = client.post(
105+
"/api/v1/admin/cleanup",
106+
json={"retention_days": 90},
107+
)
108+
assert resp.status_code == 200
109+
assert "X-RateLimit-Limit" not in resp.headers
110+
111+
112+
# ── Decorator tests ─────────────────────────────────────────────
113+
114+
115+
class TestRateLimitDecorator:
116+
def test_login_rate_limit_decorator_preserves_validation(
117+
self, client: TestClient
118+
) -> None:
119+
"""login endpoint has rate_limit(10, 60) — decorator doesn't break FastAPI."""
120+
resp = client.post("/api/v1/auth/login", json={})
121+
assert resp.status_code == 422
122+
123+
def test_refresh_rate_limit_decorator_preserves_validation(
124+
self, client: TestClient
125+
) -> None:
126+
"""refresh endpoint has rate_limit(20, 60) — decorator doesn't break FastAPI."""
127+
resp = client.post("/api/v1/auth/refresh", json={})
128+
assert resp.status_code == 422
129+
130+
131+
# ── default_key_func ────────────────────────────────────────────
132+
133+
134+
class TestDefaultKeyFunc:
135+
def test_uses_forwarded_for(self) -> None:
136+
mock_request = AsyncMock(spec=Request)
137+
mock_request.headers = {"X-Forwarded-For": "1.2.3.4, 5.6.7.8"}
138+
mock_request.url.path = "/test"
139+
key = default_key_func(mock_request)
140+
assert key == "1.2.3.4:/test"
141+
142+
def test_uses_client_host(self) -> None:
143+
mock_request = AsyncMock(spec=Request)
144+
mock_request.headers = {}
145+
mock_request.client.host = "127.0.0.1"
146+
mock_request.url.path = "/api"
147+
key = default_key_func(mock_request)
148+
assert key == "127.0.0.1:/api"

0 commit comments

Comments
 (0)