Files
konstruct/tests/unit/test_ratelimit.py
Adolfo Delorenzo 74326dfc3d 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
2026-03-23 10:32:48 -06:00

158 lines
6.4 KiB
Python

"""
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