diff --git a/tests/backends/test_memcached.py b/tests/backends/test_memcached.py index 01d2f5b..317a5e4 100644 --- a/tests/backends/test_memcached.py +++ b/tests/backends/test_memcached.py @@ -322,13 +322,21 @@ async def test_memcached_keeps_raising_after_a_connection_failure(call) -> None: @pytest.mark.asyncio -async def test_memcached_tries_a_failed_server_again_after_the_dead_timeout() -> None: - """A failed server is not skipped for pymemcache's default 60 seconds (#197).""" +async def test_memcached_tries_a_failed_server_again_after_the_dead_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A failed server is not skipped for pymemcache's default 60 seconds (#197). + + The backend hands `_DEAD_TIMEOUT` to pymemcache, which times it with the + real clock, so the test shortens it rather than wait the documented second. + """ + assert _DEAD_TIMEOUT == 1 # the delay BACKENDS.md documents + monkeypatch.setattr("fastapi_cachex.backends.memcached._DEAD_TIMEOUT", 0.05) backend = _unreachable_backend() with pytest.raises(ConnectionRefusedError): await backend.get("trip") - await asyncio.sleep(_DEAD_TIMEOUT + 0.1) + await asyncio.sleep(0.1) with pytest.raises(ConnectionRefusedError): await backend.get("k") diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index b4fc3d4..1b8070a 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -13,6 +13,15 @@ from fastapi_cachex.types import COUNTER_FINGERPRINT from fastapi_cachex.types import CacheEntry from fastapi_cachex.types import counter_entry +from tests.conftest import Clock + + +async def _until(condition: Callable[[], bool]) -> None: + """Give the background sweeper up to a second of real time to act.""" + for _ in range(100): + if condition(): + return + await asyncio.sleep(0.01) @pytest_asyncio.fixture @@ -96,7 +105,7 @@ async def test_memory_backend_clear(memory_backend: MemoryBackend): @pytest.mark.asyncio -async def test_memory_backend_ttl_expiry(memory_backend: MemoryBackend): +async def test_memory_backend_ttl_expiry(memory_backend: MemoryBackend, clock: Clock): key = "test_key" value = CacheEntry( fingerprint="test_etag", @@ -106,14 +115,14 @@ async def test_memory_backend_ttl_expiry(memory_backend: MemoryBackend): ttl = 1 await memory_backend.set(key, value, ttl) - await asyncio.sleep(2) # Wait for the TTL to expire + clock.advance(1.5) retrieved_value = await memory_backend.get(key) assert retrieved_value is None @pytest.mark.asyncio -async def test_memory_backend_cleanup(memory_backend: MemoryBackend): +async def test_memory_backend_cleanup(memory_backend: MemoryBackend, clock: Clock): key1 = "test_key1" value1 = CacheEntry( fingerprint="test_etag1", @@ -131,7 +140,7 @@ async def test_memory_backend_cleanup(memory_backend: MemoryBackend): await memory_backend.set(key1, value1, ttl1) await memory_backend.set(key2, value2, ttl2) - await asyncio.sleep(2) # Wait for the TTL of key1 to expire + clock.advance(1.5) # past key1's TTL, well within key2's await memory_backend.cleanup() retrieved_value1 = await memory_backend.get(key1) @@ -262,7 +271,9 @@ def test_aclose_only_cancels_a_task_on_another_loop(): @pytest.mark.asyncio -async def test_memory_backend_cleanup_task_impl(): +async def test_memory_backend_cleanup_task_impl( + clock: Clock, monkeypatch: pytest.MonkeyPatch +): """The sweeper itself has to drop expired entries. Reading the keys back through `get` proves nothing about the sweeper: @@ -271,16 +282,18 @@ async def test_memory_backend_cleanup_task_impl(): dictionary is inspected directly instead, and neither key is ever read. """ backend = MemoryBackend(cleanup_interval=1) + # Sweep every 10 ms of real time; the entries' TTLs run on `clock`. + monkeypatch.setattr(backend, "cleanup_interval", 0.01) expiring = CacheEntry(fingerprint="test_etag1", content=b"test_value1") surviving = CacheEntry(fingerprint="test_etag2", content=b"test_value2") await backend.set("expiring", expiring, ttl=1) await backend.set("surviving", surviving, ttl=60) + clock.advance(1.5) backend.start_cleanup() try: - # One full interval plus the entry's own TTL. - await asyncio.sleep(2) + await _until(lambda: "expiring" not in backend.cache) assert "expiring" not in backend.cache assert backend.cache["surviving"].value == surviving @@ -735,7 +748,9 @@ async def test_memory_delete_many_counts_only_existing_keys( @pytest.mark.asyncio -async def test_write_only_use_starts_the_cleanup_task(): +async def test_write_only_use_starts_the_cleanup_task( + clock: Clock, monkeypatch: pytest.MonkeyPatch +): """A backend that is only written to still needs its sweeper running. Only `get` used to start it, so a write-mostly caller — `StateManager` @@ -743,12 +758,14 @@ async def test_write_only_use_starts_the_cleanup_task(): expired entries with nothing to remove them. """ backend = MemoryBackend(cleanup_interval=1) + monkeypatch.setattr(backend, "cleanup_interval", 0.01) await backend.set("gone", CacheEntry(fingerprint="e", content=b"v"), ttl=1) await backend.set("stays", CacheEntry(fingerprint="e", content=b"v"), ttl=60) + clock.advance(1.5) try: - await asyncio.sleep(2) + await _until(lambda: "gone" not in backend.cache) # Nothing here ever calls `get`, so only the sweeper can have removed # the expired key — a task that merely exists would leave it in place. @@ -759,7 +776,7 @@ async def test_write_only_use_starts_the_cleanup_task(): @pytest.mark.asyncio -async def test_get_evicts_the_expired_entry_it_skips(): +async def test_get_evicts_the_expired_entry_it_skips(clock: Clock): """A miss on an expired key must also free the memory it was holding. Asserting that `get` returns `None` says nothing about this: it returns @@ -771,7 +788,7 @@ async def test_get_evicts_the_expired_entry_it_skips(): await backend.set("k", CacheEntry(fingerprint="e", content=b"v"), ttl=1) try: - await asyncio.sleep(1.05) + clock.advance(1.05) assert await backend.get("k") is None assert "k" not in backend.cache diff --git a/tests/conftest.py b/tests/conftest.py index df00e67..f31bcc1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,22 @@ +import asyncio +import time +from datetime import datetime +from datetime import tzinfo +from types import SimpleNamespace + import pytest import pytest_asyncio +from fastapi_cachex.backends import memory +from fastapi_cachex.backends.base import BaseCacheBackend from fastapi_cachex.backends.memory import MemoryBackend from fastapi_cachex.manager_proxy import CacheManagerProxy from fastapi_cachex.proxy import BackendProxy +from fastapi_cachex.session import manager as session_manager +from fastapi_cachex.session import models as session_models from fastapi_cachex.session.proxy import SessionManagerProxy +from fastapi_cachex.state import manager as state_manager +from fastapi_cachex.state import models as state_models from fastapi_cachex.state.proxy import StateManagerProxy # Every proxy is a process-wide singleton, so whatever one test installs is @@ -43,3 +55,46 @@ def reset_proxy_singletons(): yield for proxy in _PROXIES: proxy.set(None) + + +class Clock: + """The wall clock the library reads, moved by hand instead of by sleeping.""" + + def __init__(self) -> None: + self.now = time.time() + + def time(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + async def wait(self, seconds: float, backend: BaseCacheBackend) -> None: + """Let `seconds` pass as far as `backend` can tell. + + A live server expires keys on its own clock, which this one cannot + move, so for anything but the memory backend the time really passes. + """ + self.advance(seconds) + if not isinstance(backend, MemoryBackend): + await asyncio.sleep(seconds) + + +@pytest.fixture +def clock(monkeypatch: pytest.MonkeyPatch) -> Clock: + """Point every time read behind a TTL or expiry check at a `Clock`. + + That is `time.time()` in the memory backend and `datetime.now()` in the + session and state modules. PyJWT and live servers keep real time. + """ + clock = Clock() + + class ClockDatetime(datetime): + @classmethod + def now(cls, tz: tzinfo | None = None) -> datetime: # type: ignore[override] + return datetime.fromtimestamp(clock.now, tz) + + monkeypatch.setattr(memory, "time", SimpleNamespace(time=clock.time)) + for module in (session_manager, session_models, state_manager, state_models): + monkeypatch.setattr(module, "datetime", ClockDatetime) + return clock diff --git a/tests/session/test_jwt.py b/tests/session/test_jwt.py index c33a31b..70dc811 100644 --- a/tests/session/test_jwt.py +++ b/tests/session/test_jwt.py @@ -5,7 +5,7 @@ from __future__ import annotations -import asyncio +from typing import TYPE_CHECKING import pytest @@ -15,6 +15,9 @@ from fastapi_cachex.session.manager import SessionManager from fastapi_cachex.session.models import SessionUser +if TYPE_CHECKING: + from tests.conftest import Clock + jwt = pytest.importorskip("jwt") @@ -79,13 +82,17 @@ async def test_jwt_wrong_audience_rejected() -> None: @pytest.mark.asyncio -async def test_jwt_expiration_enforced() -> None: +async def test_jwt_expiration_enforced(clock: Clock) -> None: backend = MemoryBackend() config = SessionConfig(secret_key="a" * 32, token_format="jwt", session_ttl=1) manager = SessionManager(backend, config) + # PyJWT checks `exp` against the real time, which `clock` cannot move, so + # the session is created in the past instead: its `exp` has already gone + # by. The backend entry and the session itself go by `clock` and are still + # current, so only the token check can reject it. + clock.advance(-5) _session, token = await manager.create_session(user=SessionUser(user_id="u1")) - await asyncio.sleep(1.2) # JWT should be expired before reaching session checks with pytest.raises(SessionTokenError): diff --git a/tests/session/test_manager.py b/tests/session/test_manager.py index bd5706a..39218c0 100644 --- a/tests/session/test_manager.py +++ b/tests/session/test_manager.py @@ -25,6 +25,7 @@ from fastapi_cachex.session.models import SessionUser from fastapi_cachex.types import CacheEntry from fastapi_cachex.types import CacheItem +from tests.conftest import Clock class DummySerializer: @@ -223,10 +224,8 @@ async def test_user_agent_binding(backend: MemoryBackend) -> None: @pytest.mark.asyncio -async def test_sliding_expiration(backend: MemoryBackend) -> None: +async def test_sliding_expiration(backend: MemoryBackend, clock: Clock) -> None: """Test sliding expiration.""" - import asyncio - config = SessionConfig( secret_key="a" * 32, session_ttl=3600, @@ -240,8 +239,7 @@ async def test_sliding_expiration(backend: MemoryBackend) -> None: original_expiry = session.expires_at - # Sleep briefly to ensure time passes - await asyncio.sleep(0.01) + clock.advance(1) # Set expiry to be past the threshold (only 1000 seconds left vs 3600 TTL) session.expires_at = datetime.now(timezone.utc) + timedelta(seconds=1000) diff --git a/tests/session/test_models.py b/tests/session/test_models.py index 646b5df..f043d2c 100644 --- a/tests/session/test_models.py +++ b/tests/session/test_models.py @@ -11,6 +11,7 @@ from fastapi_cachex.session.models import SessionToken from fastapi_cachex.session.models import SessionUser from fastapi_cachex.session.token_serializers import SimpleTokenSerializer +from tests.conftest import Clock def test_session_user_creation() -> None: @@ -80,20 +81,17 @@ def test_session_is_expired() -> None: assert not session.is_expired() -def test_session_renew() -> None: +def test_session_renew(clock: Clock) -> None: """Test session renewal.""" - import time - session = Session() session.expires_at = datetime.now(timezone.utc) + timedelta(hours=1) old_expiry = session.expires_at - # Sleep briefly to ensure time difference - time.sleep(0.01) + clock.advance(1) session.renew(3600) # Renew for 1 hour - assert session.expires_at >= old_expiry + assert session.expires_at > old_expiry def test_session_regenerate_id() -> None: diff --git a/tests/state/test_manager.py b/tests/state/test_manager.py index 64b41da..c104f39 100644 --- a/tests/state/test_manager.py +++ b/tests/state/test_manager.py @@ -22,6 +22,7 @@ from fastapi_cachex.state.manager import StateManager from fastapi_cachex.state.models import StateData from fastapi_cachex.types import CacheEntry +from tests.conftest import Clock from tests.live_servers import REDIS_HOST from tests.live_servers import REDIS_PORT from tests.live_servers import requires_redis @@ -339,15 +340,14 @@ async def test_state_reuse_prevention(state_manager: StateManager) -> None: @pytest.mark.asyncio -async def test_get_state_metadata_after_expire(state_manager: StateManager) -> None: +async def test_get_state_metadata_after_expire( + state_manager: StateManager, clock: Clock +) -> None: """Test retrieving metadata from an expired state.""" # Create state with very short TTL state = await state_manager.create_state(ttl=1, metadata={"test": "data"}) - # Wait for state to expire - import asyncio - - await asyncio.sleep(1.1) + await clock.wait(1.1, state_manager.backend) # Try to retrieve metadata - should return None since it's expired retrieved = await state_manager.get_state_metadata(state) diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 2f18aad..3952608 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -17,6 +17,7 @@ from fastapi_cachex.manager_proxy import CacheManagerProxy from fastapi_cachex.proxy import BackendProxy from fastapi_cachex.types import CacheEntry +from tests.conftest import Clock from tests.live_servers import REDIS_HOST from tests.live_servers import REDIS_PORT from tests.live_servers import requires_redis @@ -216,14 +217,14 @@ async def load_user(user_id: int) -> dict[str, int]: @pytest.mark.asyncio async def test_get_or_set_honors_ttl_on_created_value( - cache_manager: CacheManager, + cache_manager: CacheManager, clock: Clock ) -> None: """get_or_set() applies the given ttl to a newly created value.""" result = await cache_manager.get_or_set("key", lambda: "value", ttl=1) assert result == "value" assert await cache_manager.get("key") == "value" - await asyncio.sleep(1.2) + await clock.wait(1.2, cache_manager.backend) assert await cache_manager.get("key") is None @@ -278,24 +279,28 @@ async def test_add_concurrent_callers_have_exactly_one_winner( @pytest.mark.asyncio -async def test_add_ttl_expires_the_claim(cache_manager: CacheManager) -> None: +async def test_add_ttl_expires_the_claim( + cache_manager: CacheManager, clock: Clock +) -> None: """An explicit ttl applies, and once it lapses the key can be added again.""" assert await cache_manager.add("event:1", "first", ttl=1) is True assert await cache_manager.add("event:1", "second", ttl=1) is False - await asyncio.sleep(1.2) + await clock.wait(1.2, cache_manager.backend) assert await cache_manager.add("event:1", "third") is True assert await cache_manager.get("event:1") == "third" @pytest.mark.asyncio -async def test_add_uses_default_ttl(memory_backend: MemoryBackend) -> None: +async def test_add_uses_default_ttl( + memory_backend: MemoryBackend, clock: Clock +) -> None: """add() without an explicit ttl falls back to the manager's default_ttl.""" manager = CacheManager(backend=memory_backend, default_ttl=1) assert await manager.add("event:1", "value") is True - await asyncio.sleep(1.2) + clock.advance(1.2) assert await manager.get("event:1") is None @@ -360,19 +365,19 @@ async def test_has_missing_key_false(cache_manager: CacheManager) -> None: @pytest.mark.asyncio -async def test_ttl_expiry(cache_manager: CacheManager) -> None: +async def test_ttl_expiry(cache_manager: CacheManager, clock: Clock) -> None: """A value set with a short ttl expires and is no longer retrievable.""" await cache_manager.set("key", "value", ttl=1) assert await cache_manager.get("key") == "value" - await asyncio.sleep(1.2) + await clock.wait(1.2, cache_manager.backend) assert await cache_manager.get("key") is None @pytest.mark.asyncio async def test_default_ttl_used_when_not_specified( - memory_backend: MemoryBackend, + memory_backend: MemoryBackend, clock: Clock ) -> None: """set() without an explicit ttl uses the manager's default_ttl.""" BackendProxy.set(memory_backend) @@ -381,21 +386,21 @@ async def test_default_ttl_used_when_not_specified( await manager.set("key", "value") assert await manager.get("key") == "value" - await asyncio.sleep(1.2) + clock.advance(1.2) assert await manager.get("key") is None @pytest.mark.asyncio async def test_explicit_ttl_overrides_default_ttl( - memory_backend: MemoryBackend, + memory_backend: MemoryBackend, clock: Clock ) -> None: """An explicit ttl on set() overrides a long default_ttl.""" BackendProxy.set(memory_backend) manager = CacheManager(default_ttl=3600) await manager.set("key", "value", ttl=1) - await asyncio.sleep(1.2) + clock.advance(1.2) assert await manager.get("key") is None diff --git a/tests/test_proxybackend.py b/tests/test_proxybackend.py index 13e0056..35cdd70 100644 --- a/tests/test_proxybackend.py +++ b/tests/test_proxybackend.py @@ -1,4 +1,3 @@ -import asyncio import threading import time from collections.abc import Iterator @@ -21,6 +20,7 @@ from fastapi_cachex.session.proxy import SessionManagerProxy from fastapi_cachex.state.proxy import StateManagerProxy from fastapi_cachex.types import CacheEntry +from tests.conftest import Clock app = FastAPI() client = TestClient(app) @@ -84,7 +84,7 @@ async def test_endpoint(): @pytest.mark.asyncio -async def test_backend_cleanup(): +async def test_backend_cleanup(clock: Clock): # Run cleanup task in async environment memory_backend = MemoryBackend() BackendProxy.set(memory_backend) @@ -101,8 +101,7 @@ async def test_backend_cleanup(): assert cached_value is not None assert cached_value.content == b"test_value" - # Wait for data to expire (1 second + extra time) - await asyncio.sleep(1.1) + clock.advance(1.1) # Execute cleanup await memory_backend.cleanup()