Skip to content

Commit 1c3803c

Browse files
authored
Update test_ratelimit.py
1 parent 80636b5 commit 1c3803c

1 file changed

Lines changed: 91 additions & 0 deletions

File tree

‎tests/test_ratelimit.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,3 +129,94 @@ def _reset_limiter():
129129
limiter.reset()
130130
yield
131131
limiter.reset()
132+
133+
134+
class TestBackendSelection:
135+
def test_memory_is_default(self) -> None:
136+
from app.infra.ratelimit import SlidingWindowLimiter, get_limiter, reset_limiter_for_tests
137+
138+
reset_limiter_for_tests()
139+
assert isinstance(get_limiter(), SlidingWindowLimiter)
140+
141+
def test_redis_selected_by_config(self, monkeypatch) -> None:
142+
from app.infra.ratelimit import RedisRateLimiter, get_limiter, reset_limiter_for_tests
143+
144+
monkeypatch.setenv("PAW_RATE_LIMIT_BACKEND", "redis")
145+
get_settings.cache_clear()
146+
reset_limiter_for_tests()
147+
try:
148+
# build fails to connect lazily only on use; construction is fine
149+
assert isinstance(get_limiter(), RedisRateLimiter)
150+
finally:
151+
get_settings.cache_clear()
152+
reset_limiter_for_tests()
153+
154+
def test_unknown_backend_raises(self, monkeypatch) -> None:
155+
from app.infra.ratelimit import get_limiter, reset_limiter_for_tests
156+
157+
monkeypatch.setenv("PAW_RATE_LIMIT_BACKEND", "nope")
158+
get_settings.cache_clear()
159+
reset_limiter_for_tests()
160+
try:
161+
with pytest.raises(ValueError, match="unknown rate-limit backend"):
162+
get_limiter()
163+
finally:
164+
get_settings.cache_clear()
165+
reset_limiter_for_tests()
166+
167+
168+
class FakeRedis:
169+
"""Minimal in-memory stand-in for the redis client surface used by
170+
RedisRateLimiter: an ``eval`` that mimics the Lua window script
171+
atomically (single-threaded Python == atomic), plus scan_iter/delete."""
172+
173+
def __init__(self) -> None:
174+
self.z: dict[str, list[tuple[float, str]]] = {}
175+
176+
def eval(self, script, numkeys, key, now, cutoff, limit, member, ttl): # noqa: ARG002
177+
now = float(now)
178+
cutoff = float(cutoff)
179+
limit = int(limit)
180+
items = [(s, m) for (s, m) in self.z.get(key, []) if not (0 <= s <= cutoff)]
181+
self.z[key] = items
182+
if len(items) >= limit:
183+
oldest = sorted(items)[0][0] if items else now
184+
return [0, str(oldest)]
185+
items.append((now, member))
186+
return [1, "0"]
187+
188+
def scan_iter(self, match=None): # noqa: ARG002
189+
return list(self.z.keys())
190+
191+
def delete(self, key):
192+
self.z.pop(key, None)
193+
194+
195+
class TestRedisRateLimiter:
196+
def test_allows_under_limit_then_blocks(self) -> None:
197+
from app.infra.ratelimit import RedisRateLimiter
198+
199+
limiter = RedisRateLimiter(client=FakeRedis())
200+
# limit 2 per 60s: first two allowed, third blocked
201+
assert limiter.check("k", 2, 60)[0] is True
202+
assert limiter.check("k", 2, 60)[0] is True
203+
allowed, retry = limiter.check("k", 2, 60)
204+
assert allowed is False
205+
assert retry > 0
206+
207+
def test_keys_are_independent(self) -> None:
208+
from app.infra.ratelimit import RedisRateLimiter
209+
210+
limiter = RedisRateLimiter(client=FakeRedis())
211+
assert limiter.check("a", 1, 60)[0] is True
212+
assert limiter.check("b", 1, 60)[0] is True # different key, own budget
213+
assert limiter.check("a", 1, 60)[0] is False
214+
215+
def test_reset_clears(self) -> None:
216+
from app.infra.ratelimit import RedisRateLimiter
217+
218+
limiter = RedisRateLimiter(client=FakeRedis())
219+
limiter.check("k", 1, 60)
220+
assert limiter.check("k", 1, 60)[0] is False
221+
limiter.reset()
222+
assert limiter.check("k", 1, 60)[0] is True

0 commit comments

Comments
 (0)