Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions tests/backends/test_memcached.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
39 changes: 28 additions & 11 deletions tests/backends/test_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -735,20 +748,24 @@ 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`
creates states without ever reading them back through `get` — accumulated
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.
Expand All @@ -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
Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
13 changes: 10 additions & 3 deletions tests/session/test_jwt.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from __future__ import annotations

import asyncio
from typing import TYPE_CHECKING

import pytest

Expand All @@ -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")


Expand Down Expand Up @@ -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):
Expand Down
8 changes: 3 additions & 5 deletions tests/session/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
10 changes: 4 additions & 6 deletions tests/session/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions tests/state/test_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading