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