feat(01-03): integration tests for Slack flow, rate limiting, and agent persona
- tests/unit/test_ratelimit.py: 11 tests for Redis token bucket (CHAN-05) - allows requests under limit, rejects 31st request - per-tenant isolation, per-channel isolation - TTL key expiry and window reset - tests/integration/test_slack_flow.py: 15 tests for end-to-end Slack flow (CHAN-02) - normalization: bot token stripped, channel=slack, thread_id set - @mention: placeholder posted in-thread, Celery dispatched with placeholder_ts - DM flow: same pipeline triggered for channel_type=im - bot messages silently ignored (no infinite loop) - unknown workspace_id silently ignored - duplicate events (Slack retries) skipped via idempotency - tests/integration/test_agent_persona.py: 15 tests for persona in prompts (AGNT-01) - system prompt contains name, role, persona, AI transparency clause - model_preference forwarded to LLM pool - full messages array: [system, user] structure verified - tests/integration/test_ratelimit.py: 4 tests for rate limit integration - over-limit -> ephemeral rejection posted - over-limit -> Celery NOT dispatched, placeholder NOT posted - within-limit -> no rejection - ephemeral message includes actionable retry hint All 45 tests pass
This commit is contained in:
157
tests/unit/test_ratelimit.py
Normal file
157
tests/unit/test_ratelimit.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
Unit tests for the Redis token bucket rate limiter.
|
||||
|
||||
Tests CHAN-05: Rate limiting enforces per-tenant, per-channel limits.
|
||||
|
||||
These tests use fakeredis to run without a live Redis instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import fakeredis
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from router.ratelimit import RateLimitExceeded, check_rate_limit
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def fake_redis():
|
||||
"""Provide a fake async Redis client backed by fakeredis."""
|
||||
r = fakeredis.aioredis.FakeRedis(decode_responses=True)
|
||||
yield r
|
||||
await r.aclose()
|
||||
|
||||
|
||||
class TestTokenBucketAllows:
|
||||
"""Tests for requests within the rate limit."""
|
||||
|
||||
async def test_single_request_allowed(self, fake_redis) -> None:
|
||||
"""A single request is always allowed."""
|
||||
result = await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
assert result is True
|
||||
|
||||
async def test_requests_under_limit_all_allowed(self, fake_redis) -> None:
|
||||
"""All 29 requests within a 30-request limit are allowed."""
|
||||
for i in range(29):
|
||||
result = await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
assert result is True, f"Request {i + 1} was unexpectedly rejected"
|
||||
|
||||
async def test_exactly_at_limit_allowed(self, fake_redis) -> None:
|
||||
"""The 30th request (exactly at limit) is allowed."""
|
||||
for _ in range(30):
|
||||
result = await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestTokenBucketRejects:
|
||||
"""Tests for requests exceeding the rate limit."""
|
||||
|
||||
async def test_31st_request_rejected(self, fake_redis) -> None:
|
||||
"""The 31st request in a 30-request window is rejected."""
|
||||
# Fill up the bucket
|
||||
for _ in range(30):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
|
||||
# 31st request must raise
|
||||
with pytest.raises(RateLimitExceeded) as exc_info:
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
|
||||
assert exc_info.value.tenant_id == "tenant_a"
|
||||
assert exc_info.value.channel == "slack"
|
||||
|
||||
async def test_rate_limit_exceeded_has_remaining_seconds(self, fake_redis) -> None:
|
||||
"""RateLimitExceeded must expose remaining_seconds attribute."""
|
||||
for _ in range(30):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30, window_seconds=60)
|
||||
|
||||
with pytest.raises(RateLimitExceeded) as exc_info:
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30, window_seconds=60)
|
||||
|
||||
assert isinstance(exc_info.value.remaining_seconds, int)
|
||||
assert exc_info.value.remaining_seconds >= 0
|
||||
|
||||
async def test_continued_requests_after_exceeded_also_rejected(self, fake_redis) -> None:
|
||||
"""Additional requests after exceeding the limit continue to be rejected."""
|
||||
for _ in range(30):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
|
||||
for _ in range(5):
|
||||
with pytest.raises(RateLimitExceeded):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
|
||||
|
||||
class TestTokenBucketTenantIsolation:
|
||||
"""Tests that rate limit counters are isolated per tenant."""
|
||||
|
||||
async def test_tenant_a_limit_independent_of_tenant_b(self, fake_redis) -> None:
|
||||
"""Exhausting tenant A's limit does not affect tenant B."""
|
||||
# Exhaust tenant A's limit
|
||||
for _ in range(30):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
with pytest.raises(RateLimitExceeded):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
|
||||
# Tenant B should still be allowed
|
||||
result = await check_rate_limit("tenant_b", "slack", fake_redis, limit=30)
|
||||
assert result is True
|
||||
|
||||
async def test_channel_limits_are_independent(self, fake_redis) -> None:
|
||||
"""Exhausting Slack limit does not affect Telegram limit."""
|
||||
for _ in range(30):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
with pytest.raises(RateLimitExceeded):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30)
|
||||
|
||||
# A different channel should still be allowed
|
||||
result = await check_rate_limit("tenant_a", "telegram", fake_redis, limit=30)
|
||||
assert result is True
|
||||
|
||||
async def test_multiple_tenants_independent_counters(self, fake_redis) -> None:
|
||||
"""Multiple tenants maintain separate counter keys."""
|
||||
tenants = ["tenant_a", "tenant_b", "tenant_c"]
|
||||
# Each tenant makes 15 requests
|
||||
for tenant in tenants:
|
||||
for _ in range(15):
|
||||
result = await check_rate_limit(tenant, "slack", fake_redis, limit=30)
|
||||
assert result is True, f"Unexpected rejection for {tenant}"
|
||||
|
||||
# None should be at the limit yet
|
||||
for tenant in tenants:
|
||||
result = await check_rate_limit(tenant, "slack", fake_redis, limit=30)
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestTokenBucketWindowReset:
|
||||
"""Tests for rate limit window expiry."""
|
||||
|
||||
async def test_limit_resets_after_window_expires(self, fake_redis) -> None:
|
||||
"""After the TTL expires, the rate limit resets and requests are allowed again."""
|
||||
# Use a very short window (1 second) to test expiry
|
||||
for _ in range(5):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=5, window_seconds=1)
|
||||
with pytest.raises(RateLimitExceeded):
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=5, window_seconds=1)
|
||||
|
||||
# Manually expire the key to simulate window reset
|
||||
from shared.redis_keys import rate_limit_key
|
||||
key = rate_limit_key("tenant_a", "slack")
|
||||
await fake_redis.delete(key)
|
||||
|
||||
# After key expiry, requests should be allowed again
|
||||
result = await check_rate_limit("tenant_a", "slack", fake_redis, limit=5, window_seconds=1)
|
||||
assert result is True
|
||||
|
||||
async def test_rate_limit_key_has_ttl(self, fake_redis) -> None:
|
||||
"""Rate limit key must have a TTL set (window expiry)."""
|
||||
from shared.redis_keys import rate_limit_key
|
||||
key = rate_limit_key("tenant_a", "slack")
|
||||
|
||||
await check_rate_limit("tenant_a", "slack", fake_redis, limit=30, window_seconds=60)
|
||||
|
||||
ttl = await fake_redis.ttl(key)
|
||||
# TTL should be set (positive) — key will expire at end of window
|
||||
assert ttl > 0
|
||||
assert ttl <= 60
|
||||
Reference in New Issue
Block a user