From 9f83e42126151467e7b0594e3a623fb3430bf2ab Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sun, 27 Sep 2026 11:01:18 +0000 Subject: [PATCH 1/3] test: run pytest in strict mode and fail on unexpected warnings - asyncio_mode = "auto" and drop the 383 per-test @pytest.mark.asyncio - testpaths, --strict-markers and xfail_strict = true - filterwarnings = ["error"]: expected warnings are asserted with pytest.warns, live Memcached resets go through flush_memcached, and the HS384/HS512 round-trip uses a key long enough for both - sync tests that called asyncio.run() are async tests now; under pytest-asyncio 0.26 they left an unclosed event loop behind - one global ignore: starlette 1.0.0's deprecated anyio alias, hit only by the lowest-dependencies env --- CLAUDE.md | 2 + docs/DEVELOPMENT.md | 18 +++++ pyproject.toml | 14 ++++ tests/backends/test_base.py | 7 -- tests/backends/test_clear_pattern_contract.py | 5 -- tests/backends/test_counter_contract.py | 3 - tests/backends/test_memcached.py | 75 ++++--------------- tests/backends/test_memory.py | 51 ------------- tests/backends/test_redis.py | 51 +------------ tests/backends/test_ttl_contract.py | 4 - tests/live_servers.py | 21 ++++++ tests/session/test_dependencies.py | 1 - tests/session/test_get_session_manager.py | 2 - tests/session/test_hardening.py | 34 ++++----- tests/session/test_jwt.py | 5 -- tests/session/test_lookup_writes.py | 5 -- tests/session/test_manager.py | 39 ---------- tests/session/test_middleware.py | 11 --- tests/session/test_starlette_middleware.py | 26 ------- tests/session/test_token_serializers.py | 3 +- tests/state/test_manager.py | 49 ------------ tests/test_cache.py | 5 +- tests/test_cache_error_paths.py | 4 - tests/test_cache_invalidate.py | 28 +++---- tests/test_cache_manager.py | 41 ---------- tests/test_cache_private.py | 4 - tests/test_cache_request_param.py | 2 - tests/test_cache_status_headers.py | 16 ++-- tests/test_dependencies.py | 5 -- tests/test_lock.py | 15 ---- tests/test_proxybackend.py | 1 - 31 files changed, 106 insertions(+), 441 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6e49003..634241e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,8 @@ Five non-abstract atomic primitives live on the base class with non-atomic fallb `tests/conftest.py` sets `MemoryBackend` as the default backend via an `autouse=True` fixture for every test. Tests requiring Redis or Memcached must configure their own backends. The `memory_backend` fixture manages the cleanup task lifecycle. +`[tool.pytest.ini_options]` sets `asyncio_mode = "auto"` (no `@pytest.mark.asyncio`), `--strict-markers`, `xfail_strict = true` and `filterwarnings = ["error"]`: an expected warning needs `pytest.warns`, and live Memcached resets go through `flush_memcached` in `tests/live_servers.py`. The one global ignore covers starlette 1.0.0's deprecated anyio alias in the `lowest` tox env. + ### Code Quality Rules - Ruff is configured with `extend-select = ['ALL']` with specific ignores (see `pyproject.toml`). Notable: E501 (line length), FBT001/FBT002 (boolean args — intentional for Cache-Control API). `fastapi.Depends` is allowed in argument defaults via `flake8-bugbear.extend-immutable-calls` rather than ignoring B008. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 83aaa52..7973564 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -82,6 +82,24 @@ server is disposable, the flag says *whether* skipping is acceptable at all. Never set the flag in a shell where the ports point at a server you care about — it makes the run louder, not safer. +### Strict pytest settings + +`[tool.pytest.ini_options]` in `pyproject.toml` keeps the suite strict: + +- `asyncio_mode = "auto"`: every `async def` test and fixture runs on + pytest-asyncio, so tests carry no `@pytest.mark.asyncio`. Write an async test + instead of calling `asyncio.run()` inside a sync one. +- `--strict-markers`: a mistyped or unregistered marker fails collection + instead of being silently ignored. +- `xfail_strict = true`: an `xfail` test that starts passing fails the run, so + the marker gets removed. +- `filterwarnings = ["error"]`: any warning fails the test that raised it. A + test that expects one asserts it with `pytest.warns(..., match=...)`. A reset + that only needs the side effect silences that one warning locally, as + `tests/live_servers.py:flush_memcached` does for `Memcached.clear()`. Add a + global ignore only for a third-party warning the tests cannot avoid, with a + comment saying why. + ### Checking that a test can fail Coverage says a line ran, not that anything checked what it did. A test that diff --git a/pyproject.toml b/pyproject.toml index 2e93006..d90e5b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,21 @@ include = ["fastapi_cachex/py.typed"] pythonpath = [ "." ] +testpaths = ["tests"] +addopts = ["--strict-markers"] +xfail_strict = true +# Every async test and fixture runs on pytest-asyncio; no per-test marker. +asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +# An unexpected warning fails the test. A test that expects one asserts it +# with pytest.warns or opts out with a narrow filterwarnings mark. +filterwarnings = [ + "error", + # starlette 1.0.0 (our floor, run by the `lowest` tox env) imports + # TestClient through an alias that newer anyio deprecates. Later starlette + # releases no longer use it; nothing in this package or its tests does. + "ignore:The anyio.abc.BlockingPortal alias is deprecated:DeprecationWarning", +] [tool.ruff.lint] ignore = [ diff --git a/tests/backends/test_base.py b/tests/backends/test_base.py index 45c0585..f5f7422 100644 --- a/tests/backends/test_base.py +++ b/tests/backends/test_base.py @@ -48,7 +48,6 @@ def backend() -> DictBackend: return DictBackend() -@pytest.mark.asyncio async def test_increment_fallback_creates_then_adds(backend: DictBackend) -> None: assert await backend.increment("hits", ttl=30) == 1 assert await backend.increment("hits", 4, ttl=30) == 5 @@ -57,7 +56,6 @@ async def test_increment_fallback_creates_then_adds(backend: DictBackend) -> Non assert backend.store["hits"] == (counter_entry(3), None) -@pytest.mark.asyncio async def test_increment_fallback_rejects_a_cached_response( backend: DictBackend, ) -> None: @@ -67,7 +65,6 @@ async def test_increment_fallback_rejects_a_cached_response( await backend.increment("page") -@pytest.mark.asyncio async def test_get_and_delete_fallback_returns_then_removes( backend: DictBackend, ) -> None: @@ -79,7 +76,6 @@ async def test_get_and_delete_fallback_returns_then_removes( assert await backend.get_and_delete("once") is None -@pytest.mark.asyncio async def test_delete_many_fallback_deletes_one_by_one(backend: DictBackend) -> None: await backend.set("a", CacheEntry(fingerprint="e", content=b"1")) await backend.set("b", CacheEntry(fingerprint="e", content=b"2")) @@ -88,7 +84,6 @@ async def test_delete_many_fallback_deletes_one_by_one(backend: DictBackend) -> assert backend.store == {} -@pytest.mark.asyncio async def test_set_if_absent_fallback_stores_only_the_first_value( backend: DictBackend, ) -> None: @@ -100,7 +95,6 @@ async def test_set_if_absent_fallback_stores_only_the_first_value( assert backend.store["slot"] == (first, 30) -@pytest.mark.asyncio async def test_delete_if_equals_fallback_removes_only_a_matching_entry( backend: DictBackend, ) -> None: @@ -115,7 +109,6 @@ async def test_delete_if_equals_fallback_removes_only_a_matching_entry( assert await backend.delete_if_equals("slot", theirs) is False -@pytest.mark.asyncio async def test_expire_if_equals_fallback_updates_ttl_only_when_matching( backend: DictBackend, ) -> None: diff --git a/tests/backends/test_clear_pattern_contract.py b/tests/backends/test_clear_pattern_contract.py index 9b618a0..e41c29d 100644 --- a/tests/backends/test_clear_pattern_contract.py +++ b/tests/backends/test_clear_pattern_contract.py @@ -71,7 +71,6 @@ async def _populate(backend: BaseCacheBackend) -> None: await backend.set(key, CacheEntry(fingerprint=key, content=b"v")) -@pytest.mark.asyncio @pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) @pytest.mark.parametrize( ("pattern", "expected_removed"), @@ -97,7 +96,6 @@ async def test_clear_pattern_globs_the_whole_key( assert len(await backend.get_all_keys()) == len(KEYS) - expected_removed -@pytest.mark.asyncio @pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) async def test_a_bare_path_pattern_warns_instead_of_clearing_nothing( backend: BaseCacheBackend, @@ -111,7 +109,6 @@ async def test_a_bare_path_pattern_warns_instead_of_clearing_nothing( assert removed == 0 -@pytest.mark.asyncio @pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) @pytest.mark.parametrize( "pattern", ["GET|||*|||/users/*", "cache:user:*", "*", "user:*", "/users/*"] @@ -132,7 +129,6 @@ async def test_patterns_that_clear_something_do_not_warn( await backend.clear_pattern(pattern) -@pytest.mark.asyncio @pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) async def test_cache_manager_clear_pattern_is_relative_to_its_namespace( backend: BaseCacheBackend, @@ -148,7 +144,6 @@ async def test_cache_manager_clear_pattern_is_relative_to_its_namespace( assert await manager.get("post:1") == {"title": "c"} -@pytest.mark.asyncio @pytest.mark.parametrize("backend", ["memory", "redis"], indirect=True) async def test_clear_path_finds_paths_with_encoded_characters( backend: BaseCacheBackend, diff --git a/tests/backends/test_counter_contract.py b/tests/backends/test_counter_contract.py index 8b88a64..bcb0131 100644 --- a/tests/backends/test_counter_contract.py +++ b/tests/backends/test_counter_contract.py @@ -64,7 +64,6 @@ async def backend(request: pytest.FixtureRequest) -> AsyncIterator[BaseCacheBack await instance.delete_many(list(KEYS)) -@pytest.mark.asyncio async def test_increment_rejects_a_cached_response_with_a_numeric_body( backend: BaseCacheBackend, ) -> None: @@ -77,7 +76,6 @@ async def test_increment_rejects_a_cached_response_with_a_numeric_body( assert await backend.get(KEYS[0]) == page -@pytest.mark.asyncio async def test_increment_continues_a_counter_written_with_set( backend: BaseCacheBackend, ) -> None: @@ -88,7 +86,6 @@ async def test_increment_continues_a_counter_written_with_set( assert await backend.get(KEYS[1]) == counter_entry(4) -@pytest.mark.asyncio async def test_a_counter_that_shrinks_reads_back_as_a_counter( backend: BaseCacheBackend, ) -> None: diff --git a/tests/backends/test_memcached.py b/tests/backends/test_memcached.py index 317a5e4..42c426e 100644 --- a/tests/backends/test_memcached.py +++ b/tests/backends/test_memcached.py @@ -16,6 +16,7 @@ from fastapi_cachex.types import CacheEntry from fastapi_cachex.types import counter_entry from tests.live_servers import MEMCACHED_SERVER +from tests.live_servers import flush_memcached from tests.live_servers import requires_memcached @@ -57,7 +58,7 @@ def mock_import(name, *args, **kwargs): @pytest_asyncio.fixture async def memcached_backend(): backend = MemcachedBackend(servers=[MEMCACHED_SERVER]) - await backend.clear() + await flush_memcached(backend) return backend @@ -71,7 +72,6 @@ def test_memcached_client_waits_for_write_acknowledgements() -> None: @requires_memcached -@pytest.mark.asyncio async def test_memcached_set_get(memcached_backend: MemcachedBackend): key = "test_key" value = CacheEntry(fingerprint="test_etag", content=b"test_content") @@ -86,7 +86,6 @@ async def test_memcached_set_get(memcached_backend: MemcachedBackend): @requires_memcached -@pytest.mark.asyncio async def test_memcached_set_without_ttl(memcached_backend: MemcachedBackend): key = "test_key" value = CacheEntry(fingerprint="test_etag", content=b"test_content") @@ -100,7 +99,6 @@ async def test_memcached_set_without_ttl(memcached_backend: MemcachedBackend): @requires_memcached -@pytest.mark.asyncio async def test_memcached_delete(memcached_backend: MemcachedBackend): key = "test_key" value = CacheEntry(fingerprint="test_etag", content=b"test_content") @@ -113,7 +111,6 @@ async def test_memcached_delete(memcached_backend: MemcachedBackend): @requires_memcached -@pytest.mark.asyncio async def test_memcached_clear(memcached_backend: MemcachedBackend): key1 = "test_key1" value1 = CacheEntry(fingerprint="test_etag1", content=b"test_content1") @@ -122,7 +119,8 @@ async def test_memcached_clear(memcached_backend: MemcachedBackend): await memcached_backend.set(key1, value1) await memcached_backend.set(key2, value2) - await memcached_backend.clear() + with pytest.warns(RuntimeWarning, match=r"Memcached\.clear\(\) flushes ALL"): + await memcached_backend.clear() retrieved_value1 = await memcached_backend.get(key1) retrieved_value2 = await memcached_backend.get(key2) @@ -132,7 +130,6 @@ async def test_memcached_clear(memcached_backend: MemcachedBackend): @requires_memcached -@pytest.mark.asyncio async def test_memcached_clear_path(memcached_backend: MemcachedBackend): # Set up test data path = "/test" @@ -150,12 +147,12 @@ async def test_memcached_clear_path(memcached_backend: MemcachedBackend): assert result is None # Test include_params=True (should return 0 as this is not supported) - cleared = await memcached_backend.clear_path(path, include_params=True) + with pytest.warns(RuntimeWarning, match="does not support pattern-based"): + cleared = await memcached_backend.clear_path(path, include_params=True) assert cleared == 0 # Should return 0 as this operation is not supported @requires_memcached -@pytest.mark.asyncio async def test_memcached_clear_path_not_match(memcached_backend: MemcachedBackend): # Set up test data path = "/test" @@ -175,7 +172,6 @@ async def test_memcached_clear_path_not_match(memcached_backend: MemcachedBacken @requires_memcached -@pytest.mark.asyncio async def test_memcached_clear_pattern(memcached_backend: MemcachedBackend): # Set up test data path = "/users/123" @@ -185,7 +181,8 @@ async def test_memcached_clear_pattern(memcached_backend: MemcachedBackend): await memcached_backend.set(path, value) # Test pattern clearing (should always return 0 as not supported) - cleared = await memcached_backend.clear_pattern("/users/*") + with pytest.warns(RuntimeWarning, match="does not support pattern matching"): + cleared = await memcached_backend.clear_pattern("/users/*") assert cleared == 0 # Should return 0 as pattern matching is not supported # Verify the original data still exists (as pattern matching is not supported) @@ -195,7 +192,6 @@ async def test_memcached_clear_pattern(memcached_backend: MemcachedBackend): @requires_memcached -@pytest.mark.asyncio async def test_memcached_clear_path_warning(memcached_backend: MemcachedBackend): # Test that warning is raised when using include_params=True with pytest.warns( @@ -207,7 +203,6 @@ async def test_memcached_clear_path_warning(memcached_backend: MemcachedBackend) @requires_memcached -@pytest.mark.asyncio async def test_memcached_clear_pattern_warning(memcached_backend: MemcachedBackend): # Test that warning is raised when using pattern matching with pytest.warns( @@ -219,11 +214,10 @@ async def test_memcached_clear_pattern_warning(memcached_backend: MemcachedBacke @requires_memcached -@pytest.mark.asyncio async def test_memcached_set_content_bytes(monkeypatch) -> None: """Test bytes content round-trip through set/get.""" backend = MemcachedBackend(servers=[MEMCACHED_SERVER]) - await backend.clear() + await flush_memcached(backend) value = CacheEntry(fingerprint="c", content=b"bytes-content") await backend.set("bytes-key", value) @@ -234,7 +228,6 @@ async def test_memcached_set_content_bytes(monkeypatch) -> None: @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_invalid_and_missing_fields( memcached_backend: MemcachedBackend, ) -> None: @@ -252,7 +245,6 @@ async def test_memcached_get_invalid_and_missing_fields( assert res2 is None -@pytest.mark.asyncio async def test_memcached_clear_path_raises_when_the_server_is_unreachable() -> None: """A connection failure is not "nothing to clear" (#177). @@ -305,7 +297,6 @@ def _unreachable_backend() -> MemcachedBackend: "clear_path", ], ) -@pytest.mark.asyncio async def test_memcached_keeps_raising_after_a_connection_failure(call) -> None: """A call right after a failed one raises instead of returning a default (#197). @@ -321,7 +312,6 @@ async def test_memcached_keeps_raising_after_a_connection_failure(call) -> None: await call(backend) -@pytest.mark.asyncio async def test_memcached_tries_a_failed_server_again_after_the_dead_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -343,7 +333,6 @@ async def test_memcached_tries_a_failed_server_again_after_the_dead_timeout( @requires_memcached -@pytest.mark.asyncio async def test_memcached_clear_path_propagates_client_errors( monkeypatch, memcached_backend: MemcachedBackend, @@ -358,19 +347,18 @@ def boom(*args, **kwargs) -> None: @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_all_keys_empty( memcached_backend: MemcachedBackend, ) -> None: """Test get_all_keys returns empty list for Memcached (unsupported).""" - await memcached_backend.clear() - keys = await memcached_backend.get_all_keys() + await flush_memcached(memcached_backend) + with pytest.warns(RuntimeWarning, match="does not support key enumeration"): + keys = await memcached_backend.get_all_keys() # Memcached doesn't support key enumeration assert keys == [] @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_all_keys_warning( memcached_backend: MemcachedBackend, ) -> None: @@ -384,19 +372,18 @@ async def test_memcached_get_all_keys_warning( @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_cache_data_empty( memcached_backend: MemcachedBackend, ) -> None: """Test get_cache_data returns empty dict for Memcached (unsupported).""" - await memcached_backend.clear() - cache_data = await memcached_backend.get_cache_data() + await flush_memcached(memcached_backend) + with pytest.warns(RuntimeWarning, match="does not support key enumeration"): + cache_data = await memcached_backend.get_cache_data() # Memcached doesn't support key enumeration assert cache_data == {} @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_cache_data_warning( memcached_backend: MemcachedBackend, ) -> None: @@ -410,7 +397,6 @@ async def test_memcached_get_cache_data_warning( @requires_memcached -@pytest.mark.asyncio async def test_memcached_concurrent_calls_do_not_share_a_socket( memcached_backend: MemcachedBackend, ) -> None: @@ -427,7 +413,6 @@ async def test_memcached_concurrent_calls_do_not_share_a_socket( @requires_memcached -@pytest.mark.asyncio async def test_memcached_increment_creates_then_adds( memcached_backend: MemcachedBackend, ) -> None: @@ -440,7 +425,6 @@ async def test_memcached_increment_creates_then_adds( @requires_memcached -@pytest.mark.asyncio async def test_memcached_increment_decrement_stops_at_zero( memcached_backend: MemcachedBackend, ) -> None: @@ -451,7 +435,6 @@ async def test_memcached_increment_decrement_stops_at_zero( @requires_memcached -@pytest.mark.asyncio async def test_memcached_increment_honors_ttl( memcached_backend: MemcachedBackend, ) -> None: @@ -463,7 +446,6 @@ async def test_memcached_increment_honors_ttl( assert await memcached_backend.increment("window", ttl=1) == 1 -@pytest.mark.asyncio async def test_memcached_increment_only_maps_non_numeric_errors_to_not_a_counter() -> ( None ): @@ -484,7 +466,6 @@ async def test_memcached_increment_only_maps_non_numeric_errors_to_not_a_counter @requires_memcached -@pytest.mark.asyncio async def test_memcached_increment_rejects_a_cached_response( memcached_backend: MemcachedBackend, ) -> None: @@ -495,7 +476,6 @@ async def test_memcached_increment_rejects_a_cached_response( @requires_memcached -@pytest.mark.asyncio async def test_memcached_increment_is_atomic_under_concurrency( memcached_backend: MemcachedBackend, ) -> None: @@ -507,7 +487,6 @@ async def test_memcached_increment_is_atomic_under_concurrency( @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_and_delete_returns_then_removes( memcached_backend: MemcachedBackend, ) -> None: @@ -520,7 +499,6 @@ async def test_memcached_get_and_delete_returns_then_removes( @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_and_delete_loses_the_race_when_cas_reports_deletion( memcached_backend: MemcachedBackend, monkeypatch ) -> None: @@ -530,7 +508,6 @@ async def test_memcached_get_and_delete_loses_the_race_when_cas_reports_deletion assert await memcached_backend.get_and_delete("once") is None -@pytest.mark.asyncio async def test_memcached_get_and_delete_retries_on_concurrent_write() -> None: """When another writer updates the key before CAS, retry gets + cas.""" backend = stubbed_backend() @@ -552,7 +529,6 @@ async def test_memcached_get_and_delete_retries_on_concurrent_write() -> None: assert backend.client.cas.call_count == 2 -@pytest.mark.asyncio async def test_memcached_get_and_delete_returns_none_when_key_deleted_before_cas() -> ( None ): @@ -569,7 +545,6 @@ async def test_memcached_get_and_delete_returns_none_when_key_deleted_before_cas assert backend.client.cas.call_count == 1 -@pytest.mark.asyncio async def test_memcached_get_and_delete_exhausts_retries_when_writes_continue() -> None: """When writers keep replacing the value, stop after max retries and raise.""" backend = stubbed_backend() @@ -588,7 +563,6 @@ async def test_memcached_get_and_delete_exhausts_retries_when_writes_continue() @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_and_delete_live_concurrent_write_regression( memcached_backend: MemcachedBackend, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -619,7 +593,6 @@ def gets_with_concurrent_write(key, *args, **kwargs): @requires_memcached -@pytest.mark.asyncio async def test_memcached_get_and_delete_has_exactly_one_winner( memcached_backend: MemcachedBackend, ) -> None: @@ -666,7 +639,6 @@ def test_keys_memcached_would_refuse_are_hashed(key: str) -> None: @requires_memcached -@pytest.mark.asyncio @pytest.mark.parametrize( "key", [ @@ -691,7 +663,6 @@ async def test_illegal_keys_round_trip_through_the_server( assert await memcached_backend.get(key) is None -@pytest.mark.asyncio async def test_ttl_beyond_thirty_days_is_sent_as_an_absolute_timestamp() -> None: """Memcached reads an exptime over 30 days as a Unix timestamp, not a duration.""" import time @@ -706,7 +677,6 @@ async def test_ttl_beyond_thirty_days_is_sent_as_an_absolute_timestamp() -> None assert now < expire <= now + sixty_days + 5 -@pytest.mark.asyncio @pytest.mark.parametrize(("ttl", "expected"), [(None, 0), (60, 60)]) async def test_short_ttls_stay_relative(ttl: int | None, expected: int) -> None: """Durations inside the boundary are passed straight through.""" @@ -749,7 +719,6 @@ async def spaced() -> dict[str, str]: BackendProxy.set(previous) -@pytest.mark.asyncio async def test_increment_reports_a_counter_that_vanished_mid_call() -> None: """ADD then INCR is two round-trips; the entry can expire in between. @@ -767,7 +736,6 @@ async def test_increment_reports_a_counter_that_vanished_mid_call() -> None: @requires_memcached -@pytest.mark.asyncio async def test_memcached_set_if_absent_stores_only_the_first_value( memcached_backend: MemcachedBackend, ): @@ -780,7 +748,6 @@ async def test_memcached_set_if_absent_stores_only_the_first_value( @requires_memcached -@pytest.mark.asyncio async def test_memcached_set_if_absent_applies_the_ttl( memcached_backend: MemcachedBackend, ): @@ -792,7 +759,6 @@ async def test_memcached_set_if_absent_applies_the_ttl( @requires_memcached -@pytest.mark.asyncio async def test_memcached_set_if_absent_has_exactly_one_winner( memcached_backend: MemcachedBackend, ): @@ -813,7 +779,6 @@ async def test_memcached_set_if_absent_has_exactly_one_winner( @requires_memcached -@pytest.mark.asyncio async def test_memcached_delete_if_equals_removes_only_a_matching_entry( memcached_backend: MemcachedBackend, ): @@ -832,7 +797,6 @@ async def test_memcached_delete_if_equals_removes_only_a_matching_entry( @requires_memcached -@pytest.mark.asyncio async def test_memcached_delete_if_equals_matches_a_counter( memcached_backend: MemcachedBackend, ): @@ -844,7 +808,6 @@ async def test_memcached_delete_if_equals_matches_a_counter( @requires_memcached -@pytest.mark.asyncio async def test_memcached_delete_if_equals_keeps_a_value_written_after_the_compare( memcached_backend: MemcachedBackend, ): @@ -872,7 +835,6 @@ def gets_then_overwrite(key): @requires_memcached -@pytest.mark.asyncio async def test_memcached_expire_if_equals_updates_ttl_only_when_matching( memcached_backend: MemcachedBackend, ): @@ -887,7 +849,6 @@ async def test_memcached_expire_if_equals_updates_ttl_only_when_matching( @requires_memcached -@pytest.mark.asyncio async def test_memcached_expire_if_equals_keeps_a_value_written_after_the_compare( memcached_backend: MemcachedBackend, ): @@ -913,7 +874,6 @@ def gets_then_overwrite(key): @requires_memcached -@pytest.mark.asyncio async def test_lock_lifecycle_with_memcached( memcached_backend: MemcachedBackend, ) -> None: @@ -938,7 +898,6 @@ def test_expiry_up_to_2038_is_sent_as_a_timestamp(monkeypatch) -> None: @pytest.mark.parametrize( "operation", ["set", "set_if_absent", "increment", "expire_if_equals"] ) -@pytest.mark.asyncio async def test_ttl_past_2038_is_rejected_before_io(operation: str) -> None: """Such a write used to succeed while Memcached dropped the item at once (#229).""" backend = stubbed_backend() @@ -952,7 +911,6 @@ async def test_ttl_past_2038_is_rejected_before_io(operation: str) -> None: @requires_memcached -@pytest.mark.asyncio async def test_memcached_delete_many_counts_only_existing_keys( memcached_backend: MemcachedBackend, ) -> None: @@ -972,7 +930,6 @@ async def test_memcached_delete_many_counts_only_existing_keys( assert await memcached_backend.get("dm-keep") == entry -@pytest.mark.asyncio async def test_memcached_delete_many_sends_each_namespaced_key_once() -> None: backend = stubbed_backend() backend.client.delete.side_effect = [True, False, True] @@ -992,7 +949,6 @@ async def test_memcached_delete_many_sends_each_namespaced_key_once() -> None: ) -@pytest.mark.asyncio async def test_memcached_delete_many_with_no_keys_does_no_io() -> None: backend = stubbed_backend() @@ -1021,7 +977,6 @@ def _stub_for_single_hop(backend: MemcachedBackend) -> None: ("delete_many", (["a", "b", "c"],)), ], ) -@pytest.mark.asyncio async def test_memcached_multi_step_operations_take_one_thread_hop( operation: str, args: tuple[object, ...], monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/backends/test_memory.py b/tests/backends/test_memory.py index 1b8070a..197cec3 100644 --- a/tests/backends/test_memory.py +++ b/tests/backends/test_memory.py @@ -36,7 +36,6 @@ def test_cleanup_interval_must_be_positive(interval: int) -> None: MemoryBackend(cleanup_interval=interval) -@pytest.mark.asyncio async def test_memory_backend_set_get(memory_backend: MemoryBackend): key = "test_key" value = CacheEntry( @@ -52,7 +51,6 @@ async def test_memory_backend_set_get(memory_backend: MemoryBackend): assert retrieved_value == value -@pytest.mark.asyncio async def test_memory_backend_get_nonexistent_key(memory_backend: MemoryBackend): key = "nonexistent_key" retrieved_value = await memory_backend.get(key) @@ -60,7 +58,6 @@ async def test_memory_backend_get_nonexistent_key(memory_backend: MemoryBackend) assert retrieved_value is None -@pytest.mark.asyncio async def test_memory_backend_delete(memory_backend: MemoryBackend): key = "test_key" value = CacheEntry( @@ -77,7 +74,6 @@ async def test_memory_backend_delete(memory_backend: MemoryBackend): assert retrieved_value is None -@pytest.mark.asyncio async def test_memory_backend_clear(memory_backend: MemoryBackend): key1 = "test_key1" value1 = CacheEntry( @@ -104,7 +100,6 @@ async def test_memory_backend_clear(memory_backend: MemoryBackend): assert retrieved_value2 is None -@pytest.mark.asyncio async def test_memory_backend_ttl_expiry(memory_backend: MemoryBackend, clock: Clock): key = "test_key" value = CacheEntry( @@ -121,7 +116,6 @@ async def test_memory_backend_ttl_expiry(memory_backend: MemoryBackend, clock: C assert retrieved_value is None -@pytest.mark.asyncio async def test_memory_backend_cleanup(memory_backend: MemoryBackend, clock: Clock): key1 = "test_key1" value1 = CacheEntry( @@ -150,7 +144,6 @@ async def test_memory_backend_cleanup(memory_backend: MemoryBackend, clock: Cloc assert retrieved_value2 == value2 -@pytest.mark.asyncio async def test_memory_backend_start_cleanup(memory_backend: MemoryBackend): memory_backend.start_cleanup() assert memory_backend._cleanup_task is not None @@ -158,7 +151,6 @@ async def test_memory_backend_start_cleanup(memory_backend: MemoryBackend): memory_backend.stop_cleanup() # Clean up after test -@pytest.mark.asyncio async def test_memory_backend_stop_cleanup(memory_backend: MemoryBackend): memory_backend.start_cleanup() assert memory_backend._cleanup_task is not None @@ -166,7 +158,6 @@ async def test_memory_backend_stop_cleanup(memory_backend: MemoryBackend): assert memory_backend._cleanup_task is None -@pytest.mark.asyncio async def test_memory_backend_double_start_cleanup(memory_backend: MemoryBackend): memory_backend.start_cleanup() original_task = memory_backend._cleanup_task @@ -175,7 +166,6 @@ async def test_memory_backend_double_start_cleanup(memory_backend: MemoryBackend memory_backend.stop_cleanup() # Clean up after test -@pytest.mark.asyncio async def test_memory_backend_stop_cleanup_when_not_running( memory_backend: MemoryBackend, ): @@ -238,7 +228,6 @@ def test_cleanup_moving_loops_cancels_the_task_on_a_loop_still_open(): _close(second) -@pytest.mark.asyncio async def test_aclose_waits_for_the_cleanup_task(memory_backend: MemoryBackend): memory_backend.start_cleanup() task = memory_backend._cleanup_task @@ -270,7 +259,6 @@ def test_aclose_only_cancels_a_task_on_another_loop(): _close(current) -@pytest.mark.asyncio async def test_memory_backend_cleanup_task_impl( clock: Clock, monkeypatch: pytest.MonkeyPatch ): @@ -301,7 +289,6 @@ async def test_memory_backend_cleanup_task_impl( backend.stop_cleanup() -@pytest.mark.asyncio async def test_memory_backend_clear_path(memory_backend: MemoryBackend): # Set up test data with proper cache key format: method|||host|||path|||query_params # default_key_builder always appends a trailing separator for query_params @@ -324,7 +311,6 @@ async def test_memory_backend_clear_path(memory_backend: MemoryBackend): assert other_value == value3 -@pytest.mark.asyncio async def test_memory_backend_clear_pattern(memory_backend: MemoryBackend): # Set up test data with proper cache key format: method|||host|||path|||query_params value1 = CacheEntry(fingerprint="test_etag1", content=b"test_value1") @@ -345,7 +331,6 @@ async def test_memory_backend_clear_pattern(memory_backend: MemoryBackend): assert posts_value == value3 -@pytest.mark.asyncio async def test_memory_backend_clear_pattern_needs_a_whole_key_glob( memory_backend: MemoryBackend, ): @@ -364,7 +349,6 @@ async def test_memory_backend_clear_pattern_needs_a_whole_key_glob( assert await memory_backend.clear_path("/users/123") == 1 -@pytest.mark.asyncio async def test_memory_backend_clear_pattern_separator_less_keys( memory_backend: MemoryBackend, ): @@ -387,7 +371,6 @@ async def test_memory_backend_clear_pattern_separator_less_keys( assert await memory_backend.get("cache:post:789") == value3 -@pytest.mark.asyncio async def test_memory_backend_clear_pattern_is_case_sensitive_on_windows( memory_backend: MemoryBackend, monkeypatch: pytest.MonkeyPatch ): @@ -401,7 +384,6 @@ async def test_memory_backend_clear_pattern_is_case_sensitive_on_windows( assert await memory_backend.clear_pattern("cache:User:*") == 1 -@pytest.mark.asyncio async def test_memory_backend_clear_path_with_colon_in_path( memory_backend: MemoryBackend, ) -> None: @@ -444,7 +426,6 @@ async def test_memory_backend_clear_path_with_colon_in_path( ) -@pytest.mark.asyncio async def test_memory_backend_clear_path_include_params( memory_backend: MemoryBackend, ) -> None: @@ -462,7 +443,6 @@ async def test_memory_backend_clear_path_include_params( assert await memory_backend.get("GET|||localhost|||/other|||") == value -@pytest.mark.asyncio async def test_memory_backend_clear_path_direct_key( memory_backend: MemoryBackend, ) -> None: @@ -480,7 +460,6 @@ async def test_memory_backend_clear_path_direct_key( assert await memory_backend.get("gitlab:template:by_tag") == value -@pytest.mark.asyncio async def test_memory_backend_clear_path_direct_key_and_separator_key( memory_backend: MemoryBackend, ) -> None: @@ -496,14 +475,12 @@ async def test_memory_backend_clear_path_direct_key_and_separator_key( assert await memory_backend.get("GET|||localhost|||my:path|||") is None -@pytest.mark.asyncio async def test_memory_backend_get_all_keys_empty(memory_backend: MemoryBackend): """Test get_all_keys returns empty list for empty cache.""" keys = await memory_backend.get_all_keys() assert keys == [] -@pytest.mark.asyncio async def test_memory_backend_get_all_keys_with_entries( memory_backend: MemoryBackend, ) -> None: @@ -523,14 +500,12 @@ async def test_memory_backend_get_all_keys_with_entries( assert len(keys) == 3 -@pytest.mark.asyncio async def test_memory_backend_get_cache_data_empty(memory_backend: MemoryBackend): """Test get_cache_data returns empty dict for empty cache.""" cache_data = await memory_backend.get_cache_data() assert cache_data == {} -@pytest.mark.asyncio async def test_memory_backend_get_cache_data_with_entries( memory_backend: MemoryBackend, ) -> None: @@ -571,7 +546,6 @@ async def _with_one_expired_entry(backend: MemoryBackend) -> None: backend.cache["stale"].expiry = time.time() - 1 -@pytest.mark.asyncio async def test_memory_backend_enumeration_skips_expired_entries( memory_backend: MemoryBackend, ) -> None: @@ -582,7 +556,6 @@ async def test_memory_backend_enumeration_skips_expired_entries( assert list(await memory_backend.get_cache_data()) == ["live"] -@pytest.mark.asyncio @pytest.mark.parametrize( "clear", [ @@ -626,7 +599,6 @@ def run_in_thread() -> bool: assert no_task_created -@pytest.mark.asyncio async def test_memory_backend_increment_creates_then_adds( memory_backend: MemoryBackend, ): @@ -641,7 +613,6 @@ async def test_memory_backend_increment_creates_then_adds( assert entry.fingerprint == COUNTER_FINGERPRINT -@pytest.mark.asyncio async def test_memory_backend_increment_applies_ttl_only_on_creation( memory_backend: MemoryBackend, ): @@ -655,7 +626,6 @@ async def test_memory_backend_increment_applies_ttl_only_on_creation( assert memory_backend.cache["window"].value == counter_entry(2) -@pytest.mark.asyncio async def test_memory_backend_increment_without_ttl_never_expires( memory_backend: MemoryBackend, ): @@ -664,7 +634,6 @@ async def test_memory_backend_increment_without_ttl_never_expires( assert memory_backend.cache["forever"].expiry is None -@pytest.mark.asyncio async def test_memory_backend_increment_restarts_an_expired_counter( memory_backend: MemoryBackend, ): @@ -674,7 +643,6 @@ async def test_memory_backend_increment_restarts_an_expired_counter( assert await memory_backend.increment("stale", ttl=60) == 1 -@pytest.mark.asyncio async def test_memory_backend_increment_rejects_a_cached_response( memory_backend: MemoryBackend, ): @@ -684,7 +652,6 @@ async def test_memory_backend_increment_rejects_a_cached_response( await memory_backend.increment("page") -@pytest.mark.asyncio async def test_memory_backend_increment_is_atomic_under_concurrency( memory_backend: MemoryBackend, ): @@ -696,7 +663,6 @@ async def test_memory_backend_increment_is_atomic_under_concurrency( assert await memory_backend.get("race") == counter_entry(100) -@pytest.mark.asyncio async def test_memory_backend_get_and_delete_returns_then_removes( memory_backend: MemoryBackend, ): @@ -708,7 +674,6 @@ async def test_memory_backend_get_and_delete_returns_then_removes( assert await memory_backend.get("once") is None -@pytest.mark.asyncio async def test_memory_backend_get_and_delete_drops_an_expired_entry( memory_backend: MemoryBackend, ): @@ -719,7 +684,6 @@ async def test_memory_backend_get_and_delete_drops_an_expired_entry( assert "stale" not in memory_backend.cache -@pytest.mark.asyncio async def test_memory_backend_get_and_delete_has_exactly_one_winner( memory_backend: MemoryBackend, ): @@ -734,7 +698,6 @@ async def test_memory_backend_get_and_delete_has_exactly_one_winner( assert results.count(None) == 19 -@pytest.mark.asyncio async def test_memory_delete_many_counts_only_existing_keys( memory_backend: MemoryBackend, ): @@ -747,7 +710,6 @@ async def test_memory_delete_many_counts_only_existing_keys( assert await memory_backend.get("keep") is not None -@pytest.mark.asyncio async def test_write_only_use_starts_the_cleanup_task( clock: Clock, monkeypatch: pytest.MonkeyPatch ): @@ -775,7 +737,6 @@ async def test_write_only_use_starts_the_cleanup_task( backend.stop_cleanup() -@pytest.mark.asyncio 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. @@ -796,7 +757,6 @@ async def test_get_evicts_the_expired_entry_it_skips(clock: Clock): backend.stop_cleanup() -@pytest.mark.asyncio async def test_read_only_use_starts_the_cleanup_task(): """A read-mostly caller needs the sweeper too. @@ -819,7 +779,6 @@ async def test_read_only_use_starts_the_cleanup_task(): backend.stop_cleanup() -@pytest.mark.asyncio async def test_cleanup_leaves_live_entries_alone(): """A sweep with nothing to do must not touch what is still valid.""" backend = MemoryBackend() @@ -834,7 +793,6 @@ async def test_cleanup_leaves_live_entries_alone(): backend.stop_cleanup() -@pytest.mark.asyncio async def test_memory_set_if_absent_stores_only_the_first_value( memory_backend: MemoryBackend, ): @@ -847,7 +805,6 @@ async def test_memory_set_if_absent_stores_only_the_first_value( assert memory_backend.cache["slot"].expiry is not None -@pytest.mark.asyncio async def test_memory_set_if_absent_without_ttl_never_expires( memory_backend: MemoryBackend, ): @@ -857,7 +814,6 @@ async def test_memory_set_if_absent_without_ttl_never_expires( assert memory_backend.cache["slot"].expiry is None -@pytest.mark.asyncio async def test_memory_set_if_absent_treats_an_expired_entry_as_absent( memory_backend: MemoryBackend, ): @@ -870,7 +826,6 @@ async def test_memory_set_if_absent_treats_an_expired_entry_as_absent( assert await memory_backend.get("slot") == fresh -@pytest.mark.asyncio async def test_memory_set_if_absent_has_exactly_one_winner( memory_backend: MemoryBackend, ): @@ -890,7 +845,6 @@ async def test_memory_set_if_absent_has_exactly_one_winner( ) -@pytest.mark.asyncio async def test_memory_delete_if_equals_removes_only_a_matching_entry( memory_backend: MemoryBackend, ): @@ -906,7 +860,6 @@ async def test_memory_delete_if_equals_removes_only_a_matching_entry( assert await memory_backend.delete_if_equals("slot", theirs) is False -@pytest.mark.asyncio async def test_memory_delete_if_equals_ignores_an_expired_entry( memory_backend: MemoryBackend, ): @@ -917,7 +870,6 @@ async def test_memory_delete_if_equals_ignores_an_expired_entry( assert await memory_backend.delete_if_equals("slot", entry) is False -@pytest.mark.asyncio async def test_memory_delete_if_equals_matches_a_counter( memory_backend: MemoryBackend, ): @@ -928,7 +880,6 @@ async def test_memory_delete_if_equals_matches_a_counter( assert await memory_backend.get("hits") is None -@pytest.mark.asyncio async def test_memory_lock_release_after_expiry_keeps_the_new_holder( memory_backend: MemoryBackend, ): @@ -945,7 +896,6 @@ async def test_memory_lock_release_after_expiry_keeps_the_new_holder( assert await memory_backend.get("slot") == owner_b -@pytest.mark.asyncio async def test_memory_expire_if_equals_updates_ttl_only_when_matching( memory_backend: MemoryBackend, ): @@ -959,7 +909,6 @@ async def test_memory_expire_if_equals_updates_ttl_only_when_matching( assert await memory_backend.expire_if_equals("missing", theirs, 60) is False -@pytest.mark.asyncio async def test_memory_expire_if_equals_ignores_an_expired_entry( memory_backend: MemoryBackend, ): diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index 3574ec3..a1cfb0e 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -89,7 +89,6 @@ def test_redis_load_from_config_warns_on_non_utf8_encoding() -> None: AsyncRedisCacheBackend.load_from_config(RedisConfig(encoding="latin-1")) -@pytest.mark.asyncio async def test_redis_latin1_encoding_corrupts_non_ascii_content() -> None: """What the warning is about, on a live server (#122).""" reason = redis_skip_reason() @@ -230,7 +229,6 @@ def mock_import(name, *args, **kwargs): assert "redis[hiredis] is not installed" in str(exc_info.value) -@pytest.mark.asyncio class TestAsyncRedisCacheBackend: @requires_redis async def test_get_nonexistent(self, async_redis_backend: AsyncRedisCacheBackend): @@ -304,7 +302,6 @@ async def test_clear_pattern(self, async_redis_backend: AsyncRedisCacheBackend): @requires_redis -@pytest.mark.asyncio async def test_redis_ttl(async_redis_backend: AsyncRedisCacheBackend): """Test TTL functionality.""" value = CacheEntry(fingerprint="test-etag", content=b"test-content") @@ -320,7 +317,6 @@ async def test_redis_ttl(async_redis_backend: AsyncRedisCacheBackend): @requires_redis -@pytest.mark.asyncio async def test_redis_deserialize_invalid_json( async_redis_backend: AsyncRedisCacheBackend, ): @@ -343,7 +339,6 @@ async def test_redis_deserialize_invalid_json( @requires_redis -@pytest.mark.asyncio async def test_redis_clear_path_no_matches(async_redis_backend: AsyncRedisCacheBackend): """Test clear_path when no keys match the pattern.""" cleared = await async_redis_backend.clear_path("/nonexistent/") @@ -351,7 +346,6 @@ async def test_redis_clear_path_no_matches(async_redis_backend: AsyncRedisCacheB @requires_redis -@pytest.mark.asyncio async def test_redis_clear_path_direct_key( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -385,7 +379,6 @@ async def test_redis_clear_path_direct_key( @requires_redis -@pytest.mark.asyncio async def test_redis_clear_path_direct_key_and_separator_key( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -402,17 +395,16 @@ async def test_redis_clear_path_direct_key_and_separator_key( @requires_redis -@pytest.mark.asyncio async def test_redis_clear_pattern_no_matches( async_redis_backend: AsyncRedisCacheBackend, ): """Test clear_pattern when no keys match the pattern.""" - cleared = await async_redis_backend.clear_pattern("/nonexistent/*") + with pytest.warns(RuntimeWarning, match="clear_path"): + cleared = await async_redis_backend.clear_pattern("/nonexistent/*") assert cleared == 0 @requires_redis -@pytest.mark.asyncio async def test_redis_clear_pattern_with_prefixed_pattern( async_redis_backend: AsyncRedisCacheBackend, ): @@ -430,7 +422,6 @@ async def test_redis_clear_pattern_with_prefixed_pattern( @requires_redis -@pytest.mark.asyncio async def test_redis_clear_pattern_prefixes_a_pattern_that_starts_with_the_prefix( async_redis_backend: AsyncRedisCacheBackend, ): @@ -449,7 +440,6 @@ async def test_redis_clear_pattern_prefixes_a_pattern_that_starts_with_the_prefi assert await async_redis_backend.get("user:1") == value -@pytest.mark.asyncio async def test_redis_cache_manager_clear_pattern_with_matching_prefixes() -> None: """The #109 reproduction: backend and CacheManager both use ``cache:``.""" reason = redis_skip_reason() @@ -475,7 +465,6 @@ async def test_redis_cache_manager_clear_pattern_with_matching_prefixes() -> Non @requires_redis -@pytest.mark.asyncio async def test_redis_clear_path_exact_without_params( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -493,7 +482,6 @@ async def test_redis_clear_path_exact_without_params( @requires_redis -@pytest.mark.asyncio async def test_redis_clear_path_with_colon_in_path( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -556,7 +544,6 @@ async def test_redis_clear_path_with_colon_in_path( @requires_redis -@pytest.mark.asyncio async def test_redis_deserialize_non_string_content( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -571,7 +558,6 @@ async def test_redis_deserialize_non_string_content( @requires_redis -@pytest.mark.asyncio async def test_redis_set_get_with_bytes_content( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -585,7 +571,6 @@ async def test_redis_set_get_with_bytes_content( @requires_redis -@pytest.mark.asyncio async def test_redis_get_all_keys_empty( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -597,7 +582,6 @@ async def test_redis_get_all_keys_empty( @requires_redis -@pytest.mark.asyncio async def test_redis_get_all_keys_with_entries( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -628,7 +612,6 @@ async def test_redis_get_all_keys_with_entries( @requires_redis -@pytest.mark.asyncio async def test_redis_get_cache_data_empty( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -640,7 +623,6 @@ async def test_redis_get_cache_data_empty( @requires_redis -@pytest.mark.asyncio async def test_redis_get_cache_data_with_entries( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -678,7 +660,6 @@ async def test_redis_get_cache_data_with_entries( @requires_redis -@pytest.mark.asyncio async def test_redis_increment_with_a_float_ttl_leaves_no_counter( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -696,7 +677,6 @@ async def test_redis_increment_with_a_float_ttl_leaves_no_counter( @requires_redis -@pytest.mark.asyncio async def test_redis_increment_creates_then_adds( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -710,7 +690,6 @@ async def test_redis_increment_creates_then_adds( @requires_redis -@pytest.mark.asyncio async def test_redis_increment_applies_ttl_only_on_creation( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -730,7 +709,6 @@ async def test_redis_increment_applies_ttl_only_on_creation( @requires_redis -@pytest.mark.asyncio async def test_redis_increment_rejects_a_cached_response( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -743,7 +721,6 @@ async def test_redis_increment_rejects_a_cached_response( @requires_redis -@pytest.mark.asyncio async def test_redis_increment_reraises_other_response_errors( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -760,7 +737,6 @@ async def boom(**kwargs): @requires_redis -@pytest.mark.asyncio async def test_redis_increment_is_atomic_under_concurrency( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -773,7 +749,6 @@ async def test_redis_increment_is_atomic_under_concurrency( @requires_redis -@pytest.mark.asyncio async def test_redis_get_and_delete_returns_then_removes( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -786,7 +761,6 @@ async def test_redis_get_and_delete_returns_then_removes( @requires_redis -@pytest.mark.asyncio async def test_redis_get_and_delete_has_exactly_one_winner( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -802,7 +776,6 @@ async def test_redis_get_and_delete_has_exactly_one_winner( @requires_redis -@pytest.mark.asyncio async def test_redis_delete_many_counts_only_existing_keys( async_redis_backend: AsyncRedisCacheBackend, ): @@ -816,7 +789,6 @@ async def test_redis_delete_many_counts_only_existing_keys( @requires_redis -@pytest.mark.asyncio async def test_redis_scan_walks_every_page( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -839,7 +811,6 @@ async def test_redis_scan_walks_every_page( @requires_redis -@pytest.mark.asyncio async def test_redis_scan_results_are_deduplicated( async_redis_backend: AsyncRedisCacheBackend, monkeypatch: pytest.MonkeyPatch, @@ -901,7 +872,6 @@ async def _fill_pages(backend: AsyncRedisCacheBackend) -> int: @requires_redis -@pytest.mark.asyncio @pytest.mark.parametrize( "clear", [ @@ -928,7 +898,6 @@ async def test_redis_clear_deletes_each_scan_page_as_it_arrives( @requires_redis -@pytest.mark.asyncio async def test_redis_clear_path_does_not_check_exists( async_redis_backend: AsyncRedisCacheBackend, monkeypatch: pytest.MonkeyPatch, @@ -945,7 +914,6 @@ async def test_redis_clear_path_does_not_check_exists( @requires_redis -@pytest.mark.asyncio async def test_redis_clear_counts_a_key_scan_repeats_once( async_redis_backend: AsyncRedisCacheBackend, monkeypatch: pytest.MonkeyPatch, @@ -968,7 +936,6 @@ async def scan_with_repeats(*args: Any, **kwargs: Any) -> tuple[int, list[str]]: @requires_redis -@pytest.mark.asyncio async def test_redis_get_cache_data_reports_absolute_expiry( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -988,7 +955,6 @@ async def test_redis_get_cache_data_reports_absolute_expiry( @requires_redis -@pytest.mark.asyncio async def test_redis_get_cache_data_uses_chunked_non_transactional_pipelines( async_redis_backend: AsyncRedisCacheBackend, monkeypatch: pytest.MonkeyPatch, @@ -1026,7 +992,6 @@ async def execute(*e_args: Any, **e_kwargs: Any) -> Any: @requires_redis -@pytest.mark.asyncio async def test_redis_get_cache_data_skips_keys_gone_after_scan( async_redis_backend: AsyncRedisCacheBackend, monkeypatch: pytest.MonkeyPatch, @@ -1045,7 +1010,6 @@ async def keys_with_a_ghost() -> list[str]: @requires_redis -@pytest.mark.asyncio async def test_redis_get_cache_data_skips_undecodable_values( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1062,7 +1026,6 @@ async def test_redis_get_cache_data_skips_undecodable_values( @requires_redis -@pytest.mark.asyncio async def test_redis_set_if_absent_stores_only_the_first_value( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1083,7 +1046,6 @@ async def test_redis_set_if_absent_stores_only_the_first_value( @requires_redis -@pytest.mark.asyncio async def test_redis_set_if_absent_has_exactly_one_winner( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1104,7 +1066,6 @@ async def test_redis_set_if_absent_has_exactly_one_winner( @requires_redis -@pytest.mark.asyncio async def test_redis_delete_if_equals_removes_only_a_matching_entry( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1121,7 +1082,6 @@ async def test_redis_delete_if_equals_removes_only_a_matching_entry( @requires_redis -@pytest.mark.asyncio async def test_redis_delete_if_equals_matches_a_counter( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1134,7 +1094,6 @@ async def test_redis_delete_if_equals_matches_a_counter( @requires_redis -@pytest.mark.asyncio async def test_redis_delete_if_equals_keeps_a_value_written_after_the_compare( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1162,7 +1121,6 @@ async def get_then_overwrite(name): @requires_redis -@pytest.mark.asyncio async def test_redis_lock_release_after_expiry_keeps_the_new_holder( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1178,7 +1136,6 @@ async def test_redis_lock_release_after_expiry_keeps_the_new_holder( @requires_redis -@pytest.mark.asyncio async def test_redis_expire_if_equals_updates_ttl_only_when_matching( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1194,7 +1151,6 @@ async def test_redis_expire_if_equals_updates_ttl_only_when_matching( @requires_redis -@pytest.mark.asyncio async def test_redis_expire_if_equals_keeps_a_value_written_after_the_compare( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1257,7 +1213,6 @@ async def ttl_zero() -> dict[str, str]: @requires_redis -@pytest.mark.asyncio async def test_redis_clear_path_matches_glob_characters_literally( async_redis_backend: AsyncRedisCacheBackend, ) -> None: @@ -1278,7 +1233,6 @@ async def test_redis_clear_path_matches_glob_characters_literally( ] -@pytest.mark.asyncio async def test_redis_glob_characters_in_prefix_do_not_reach_other_prefixes() -> None: """clear/get_all_keys/clear_pattern stay inside a prefix containing ``?``/``*``.""" reason = redis_skip_reason() @@ -1313,7 +1267,6 @@ def make(prefix: str) -> AsyncRedisCacheBackend: @requires_redis @requires_redis_package -@pytest.mark.asyncio async def test_lock_lifecycle_with_redis( async_redis_backend: AsyncRedisCacheBackend, ) -> None: diff --git a/tests/backends/test_ttl_contract.py b/tests/backends/test_ttl_contract.py index f7e5bd0..3ad97ad 100644 --- a/tests/backends/test_ttl_contract.py +++ b/tests/backends/test_ttl_contract.py @@ -68,7 +68,6 @@ def make_backends() -> list[BaseCacheBackend]: @pytest.mark.parametrize(("ttl", "error", "match"), BAD_TTLS) @pytest.mark.parametrize("operation", ["set", "set_if_absent", "increment"]) -@pytest.mark.asyncio async def test_backends_reject_invalid_ttl( operation: str, ttl: object, error: type[Exception], match: str ) -> None: @@ -91,7 +90,6 @@ def test_validate_ttl_passes_none_and_positive(ttl: int | None) -> None: @pytest.mark.parametrize(("ttl", "error", "match"), BAD_TTLS) -@pytest.mark.asyncio async def test_cache_manager_rejects_invalid_ttl( ttl: Any, error: type[Exception], match: str ) -> None: @@ -113,7 +111,6 @@ async def test_cache_manager_rejects_invalid_ttl( @pytest.mark.parametrize(("ttl", "error", "match"), BAD_TTLS) -@pytest.mark.asyncio async def test_state_manager_rejects_invalid_ttl( ttl: Any, error: type[Exception], match: str ) -> None: @@ -136,7 +133,6 @@ async def test_state_manager_rejects_invalid_ttl( pytest.param(-(2**63) - 1, ValueError, "signed 64-bit", id="too-small"), ], ) -@pytest.mark.asyncio async def test_backends_reject_invalid_delta( delta: Any, error: type[Exception], match: str ) -> None: diff --git a/tests/live_servers.py b/tests/live_servers.py index 44c2a8d..7291602 100644 --- a/tests/live_servers.py +++ b/tests/live_servers.py @@ -36,9 +36,14 @@ import os import socket +import warnings +from typing import TYPE_CHECKING import pytest +if TYPE_CHECKING: + from fastapi_cachex.backends import MemcachedBackend + REDIS_PORT_ENV = "CACHEX_TEST_REDIS_PORT" REDIS_HOST_ENV = "CACHEX_TEST_REDIS_HOST" MEMCACHED_PORT_ENV = "CACHEX_TEST_MEMCACHED_PORT" @@ -80,6 +85,22 @@ def _explicit_port(env_var: str) -> int | None: MEMCACHED_SERVER = f"{MEMCACHED_HOST}:{MEMCACHED_PORT}" +async def flush_memcached(backend: "MemcachedBackend") -> None: + """Wipe the disposable Memcached server between tests. + + `clear()` is `flush_all` and warns that it wipes the whole server, which is + exactly what a reset of a throwaway test server wants. Only that warning is + silenced; the test that checks it uses `pytest.warns`. + """ + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + message=r"Memcached\.clear\(\) flushes ALL", + category=RuntimeWarning, + ) + await backend.clear() + + def _port_is_open(host: str, port: int) -> bool: """Return True if something accepts a TCP connection on host:port.""" s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/tests/session/test_dependencies.py b/tests/session/test_dependencies.py index 63a6135..bc008c3 100644 --- a/tests/session/test_dependencies.py +++ b/tests/session/test_dependencies.py @@ -92,7 +92,6 @@ async def protected(session=Depends(require_session)): @pytest.mark.filterwarnings( "ignore:SessionMiddleware is deprecated:DeprecationWarning" ) - @pytest.mark.asyncio async def test_require_session_with_valid_session(self) -> None: """require_session passes when a valid session is present.""" config = SessionConfig(secret_key="a" * 32) diff --git a/tests/session/test_get_session_manager.py b/tests/session/test_get_session_manager.py index 9ede78e..b03d4a4 100644 --- a/tests/session/test_get_session_manager.py +++ b/tests/session/test_get_session_manager.py @@ -41,7 +41,6 @@ async def test_endpoint( @pytest.mark.filterwarnings("ignore:SessionMiddleware is deprecated:DeprecationWarning") -@pytest.mark.asyncio async def test_get_session_manager_allows_create_session( manager: SessionManager, config: SessionConfig ) -> None: @@ -92,7 +91,6 @@ async def test_endpoint( @pytest.mark.filterwarnings("ignore:SessionMiddleware is deprecated:DeprecationWarning") -@pytest.mark.asyncio async def test_get_session_manager_full_workflow( manager: SessionManager, config: SessionConfig ) -> None: diff --git a/tests/session/test_hardening.py b/tests/session/test_hardening.py index 85f517d..59d7c53 100644 --- a/tests/session/test_hardening.py +++ b/tests/session/test_hardening.py @@ -66,7 +66,7 @@ async def whoami(request: Request) -> dict[str, bool]: assert response.json() == {"authenticated": False} -def test_forged_forwarded_header_cannot_satisfy_ip_binding( +async def test_forged_forwarded_header_cannot_satisfy_ip_binding( manager: SessionManager, config: SessionConfig ): """A stolen token plus a forged X-Forwarded-For must not pass IP binding.""" @@ -85,13 +85,9 @@ async def me(request: Request) -> dict[str, bool]: # TestClient presents itself as "testclient"; bind a session to some other # address and then try to claim that address via the header. - import asyncio - - _, token = asyncio.run( - manager.create_session( - user=SessionUser(user_id="u1"), - ip_address="203.0.113.7", - ) + _, token = await manager.create_session( + user=SessionUser(user_id="u1"), + ip_address="203.0.113.7", ) response = client.get( @@ -110,11 +106,9 @@ async def me(request: Request) -> dict[str, bool]: # Positive control: the same token bound to the address the request really # comes from is honoured. Without this, an implementation that refused # every session would pass the assertion above. - _, bound_token = asyncio.run( - manager.create_session( - user=SessionUser(user_id="u2"), - ip_address="testclient", - ) + _, bound_token = await manager.create_session( + user=SessionUser(user_id="u2"), + ip_address="testclient", ) honoured = client.get("/me", headers={"X-Session-Token": bound_token}) @@ -122,7 +116,9 @@ async def me(request: Request) -> dict[str, bool]: assert honoured.json() == {"authenticated": True} -def test_prepended_forwarded_entry_cannot_satisfy_ip_binding(manager: SessionManager): +async def test_prepended_forwarded_entry_cannot_satisfy_ip_binding( + manager: SessionManager, +): """Behind a trusted proxy, the attacker's own entry must not be believed. `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` appends, so a @@ -147,13 +143,9 @@ async def me(request: Request) -> dict[str, bool]: loaded = request.scope["state"].get("__fastapi_cachex_session") return {"authenticated": loaded is not None} - import asyncio - - _, token = asyncio.run( - manager.create_session( - user=SessionUser(user_id="u1"), - ip_address="198.51.100.5", - ) + _, token = await manager.create_session( + user=SessionUser(user_id="u1"), + ip_address="198.51.100.5", ) client = TestClient(app) diff --git a/tests/session/test_jwt.py b/tests/session/test_jwt.py index 70dc811..1c22372 100644 --- a/tests/session/test_jwt.py +++ b/tests/session/test_jwt.py @@ -21,7 +21,6 @@ jwt = pytest.importorskip("jwt") -@pytest.mark.asyncio async def test_jwt_create_and_get_session() -> None: backend = MemoryBackend() config = SessionConfig( @@ -43,7 +42,6 @@ async def test_jwt_create_and_get_session() -> None: assert retrieved.user.user_id == "u1" -@pytest.mark.asyncio async def test_jwt_invalid_signature_rejected() -> None: backend = MemoryBackend() config = SessionConfig(secret_key="a" * 32, token_format="jwt") @@ -58,7 +56,6 @@ async def test_jwt_invalid_signature_rejected() -> None: await manager.get_session(tampered) -@pytest.mark.asyncio async def test_jwt_wrong_audience_rejected() -> None: backend = MemoryBackend() config1 = SessionConfig( @@ -81,7 +78,6 @@ async def test_jwt_wrong_audience_rejected() -> None: await manager2.get_session(token) -@pytest.mark.asyncio async def test_jwt_expiration_enforced(clock: Clock) -> None: backend = MemoryBackend() config = SessionConfig(secret_key="a" * 32, token_format="jwt", session_ttl=1) @@ -99,7 +95,6 @@ async def test_jwt_expiration_enforced(clock: Clock) -> None: await manager.get_session(token) -@pytest.mark.asyncio async def test_jwt_sliding_renewal_returns_new_token_with_updated_exp() -> None: """Sliding renewal must extend the session's expires_at back to a full TTL.""" from datetime import datetime diff --git a/tests/session/test_lookup_writes.py b/tests/session/test_lookup_writes.py index e6fadf7..29efb40 100644 --- a/tests/session/test_lookup_writes.py +++ b/tests/session/test_lookup_writes.py @@ -51,7 +51,6 @@ async def _stored(manager: SessionManager, session_id: str) -> Session: return stored -@pytest.mark.asyncio @pytest.mark.parametrize("sliding_expiration", [True, False]) async def test_plain_lookup_does_not_write(sliding_expiration: bool) -> None: """A lookup that renews nothing leaves the backend untouched.""" @@ -66,7 +65,6 @@ async def test_plain_lookup_does_not_write(sliding_expiration: bool) -> None: assert loaded.session_id == session.session_id -@pytest.mark.asyncio async def test_sliding_renewal_writes_once_and_extends_the_stored_expiry() -> None: """A renewal must reach the backend, or its TTL would not move.""" manager, backend = _manager(session_ttl=3600, sliding_threshold=0.5) @@ -84,7 +82,6 @@ async def test_sliding_renewal_writes_once_and_extends_the_stored_expiry() -> No assert stored.expires_at > datetime.now(timezone.utc) + timedelta(seconds=3000) # type: ignore[operator] -@pytest.mark.asyncio async def test_touch_stores_last_accessed() -> None: """``touch=True`` keeps the stored ``last_accessed`` exact.""" manager, backend = _manager() @@ -103,7 +100,6 @@ async def test_touch_stores_last_accessed() -> None: assert stored.last_accessed == touched.last_accessed -@pytest.mark.asyncio async def test_session_entries_use_a_constant_fingerprint() -> None: """Nothing compares session fingerprints, so the payload is not hashed.""" manager, backend = _manager() @@ -142,7 +138,6 @@ async def bump(request: Request) -> dict[str, object]: assert client.get("/read").json() == {"count": 2} -@pytest.mark.asyncio @pytest.mark.filterwarnings("ignore::DeprecationWarning") async def test_header_middleware_lookup_does_not_write() -> None: """The deprecated header middleware loads sessions the same way.""" diff --git a/tests/session/test_manager.py b/tests/session/test_manager.py index 39218c0..3cfcab2 100644 --- a/tests/session/test_manager.py +++ b/tests/session/test_manager.py @@ -61,7 +61,6 @@ def test_session_manager_accepts_secretstr(backend: MemoryBackend) -> None: assert len(signature) == 64 -@pytest.mark.asyncio async def test_create_session(manager: SessionManager) -> None: """Test creating a session.""" user = SessionUser(user_id="123", username="testuser") @@ -73,7 +72,6 @@ async def test_create_session(manager: SessionManager) -> None: assert token is not None -@pytest.mark.asyncio async def test_create_anonymous_session(manager: SessionManager) -> None: """Test creating an anonymous session.""" session, token = await manager.create_anonymous_session() @@ -85,7 +83,6 @@ async def test_create_anonymous_session(manager: SessionManager) -> None: assert retrieved.user is None -@pytest.mark.asyncio async def test_get_session(manager: SessionManager) -> None: """Test retrieving a session.""" user = SessionUser(user_id="123", username="testuser") @@ -97,14 +94,12 @@ async def test_get_session(manager: SessionManager) -> None: assert retrieved_session.user.user_id == "123" # type: ignore[union-attr] -@pytest.mark.asyncio async def test_get_invalid_token(manager: SessionManager) -> None: """Test getting session with invalid token.""" with pytest.raises(SessionTokenError): await manager.get_session("invalid-token") -@pytest.mark.asyncio async def test_session_errors_are_caught_as_cachex_errors( manager: SessionManager, ) -> None: @@ -115,7 +110,6 @@ async def test_session_errors_are_caught_as_cachex_errors( assert isinstance(exc_info.value, SessionTokenError) -@pytest.mark.asyncio async def test_get_nonexistent_session(manager: SessionManager) -> None: """Test getting nonexistent session.""" # Create a valid token for a session that doesn't exist @@ -132,7 +126,6 @@ async def test_get_nonexistent_session(manager: SessionManager) -> None: await manager.get_session(serializer.to_string(token)) -@pytest.mark.asyncio async def test_session_expiry(manager: SessionManager) -> None: """Test session expiry.""" # Create session with short TTL @@ -148,7 +141,6 @@ async def test_session_expiry(manager: SessionManager) -> None: await manager.get_session(token) -@pytest.mark.asyncio async def test_delete_session(manager: SessionManager) -> None: """Test deleting a session.""" user = SessionUser(user_id="123", username="testuser") @@ -160,7 +152,6 @@ async def test_delete_session(manager: SessionManager) -> None: await manager.get_session(token) -@pytest.mark.asyncio async def test_regenerate_session_id(manager: SessionManager) -> None: """Test regenerating session ID.""" user = SessionUser(user_id="123", username="testuser") @@ -181,7 +172,6 @@ async def test_regenerate_session_id(manager: SessionManager) -> None: assert retrieved.session_id == updated_session.session_id -@pytest.mark.asyncio async def test_ip_binding(backend: MemoryBackend) -> None: """Test IP address binding.""" config = SessionConfig(secret_key="a" * 32, ip_binding=True) @@ -202,7 +192,6 @@ async def test_ip_binding(backend: MemoryBackend) -> None: await manager.get_session(token, ip_address="192.168.1.2") -@pytest.mark.asyncio async def test_user_agent_binding(backend: MemoryBackend) -> None: """Test User-Agent binding.""" config = SessionConfig(secret_key="a" * 32, user_agent_binding=True) @@ -223,7 +212,6 @@ async def test_user_agent_binding(backend: MemoryBackend) -> None: await manager.get_session(token, user_agent="Chrome/91.0") -@pytest.mark.asyncio async def test_sliding_expiration(backend: MemoryBackend, clock: Clock) -> None: """Test sliding expiration.""" config = SessionConfig( @@ -254,7 +242,6 @@ async def test_sliding_expiration(backend: MemoryBackend, clock: Clock) -> None: assert new_token is not None -@pytest.mark.asyncio async def test_invalidate_session(manager: SessionManager) -> None: """Test invalidating a session.""" user = SessionUser(user_id="123", username="testuser") @@ -267,7 +254,6 @@ async def test_invalidate_session(manager: SessionManager) -> None: await manager.get_session(token) -@pytest.mark.asyncio async def test_clear_expired_sessions(backend: MemoryBackend) -> None: """Test clearing expired sessions.""" config = SessionConfig(secret_key="a" * 32, session_ttl=1) @@ -294,7 +280,6 @@ async def test_clear_expired_sessions(backend: MemoryBackend) -> None: assert count == 1 # Only one session should be cleared -@pytest.mark.asyncio async def test_save_session_with_expired_session( backend: MemoryBackend, ) -> None: @@ -316,7 +301,6 @@ async def test_save_session_with_expired_session( await manager.get_session(token) -@pytest.mark.asyncio async def test_update_session(manager: SessionManager) -> None: """Test updating a session.""" user = SessionUser(user_id="123", username="testuser") @@ -331,7 +315,6 @@ async def test_update_session(manager: SessionManager) -> None: assert retrieved.data.get("updated_field") == "updated_value" -@pytest.mark.asyncio async def test_delete_user_sessions(manager: SessionManager) -> None: """Test deleting all sessions for a specific user.""" user1 = SessionUser(user_id="user1", username="testuser1") @@ -354,7 +337,6 @@ async def test_delete_user_sessions(manager: SessionManager) -> None: assert retrieved.session_id == session3.session_id -@pytest.mark.asyncio async def test_invalid_session_signature( backend: MemoryBackend, ) -> None: @@ -382,7 +364,6 @@ async def test_invalid_session_signature( await manager.get_session(serializer.to_string(tampered_token)) -@pytest.mark.asyncio async def test_session_manager_respects_custom_serializer( backend: MemoryBackend, ) -> None: @@ -401,7 +382,6 @@ async def test_session_manager_respects_custom_serializer( assert serializer.last_token is not None -@pytest.mark.asyncio async def test_jwt_token_format_uses_serializer( monkeypatch: pytest.MonkeyPatch, backend: MemoryBackend ) -> None: @@ -446,7 +426,6 @@ def from_string(self, token_str: str) -> SessionToken: assert stub.from_string_payloads == [token] -@pytest.mark.asyncio async def test_load_session_by_key_invalid_payload_returns_none( manager: SessionManager, backend: MemoryBackend ) -> None: @@ -462,7 +441,6 @@ async def test_load_session_by_key_invalid_payload_returns_none( assert session is None -@pytest.mark.asyncio async def test_save_session_without_ttl_uses_none_expiry( backend: MemoryBackend, ) -> None: @@ -489,7 +467,6 @@ async def test_save_session_without_ttl_uses_none_expiry( # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_absolute_timeout_raises_session_expired_error( backend: MemoryBackend, ) -> None: @@ -512,7 +489,6 @@ async def test_absolute_timeout_raises_session_expired_error( await manager.get_session(token) -@pytest.mark.asyncio async def test_absolute_timeout_zero_disables_enforcement( backend: MemoryBackend, ) -> None: @@ -528,7 +504,6 @@ async def test_absolute_timeout_zero_disables_enforcement( assert retrieved is not None -@pytest.mark.asyncio async def test_absolute_timeout_not_triggered_before_expiry( backend: MemoryBackend, ) -> None: @@ -552,7 +527,6 @@ async def _age(manager: SessionManager, session: Session, seconds: int) -> None: await manager.update_session(session) -@pytest.mark.asyncio async def test_sliding_renewal_stops_at_absolute_timeout( backend: MemoryBackend, ) -> None: @@ -581,7 +555,6 @@ async def test_sliding_renewal_stops_at_absolute_timeout( assert item.expiry <= cap.timestamp() -@pytest.mark.asyncio async def test_no_renewal_once_expiry_reaches_absolute_timeout( backend: MemoryBackend, ) -> None: @@ -603,7 +576,6 @@ async def test_no_renewal_once_expiry_reaches_absolute_timeout( assert second is None -@pytest.mark.asyncio async def test_new_session_expiry_is_capped_by_absolute_timeout( backend: MemoryBackend, ) -> None: @@ -616,7 +588,6 @@ async def test_new_session_expiry_is_capped_by_absolute_timeout( assert session.expires_at == session.created_at + timedelta(seconds=60) -@pytest.mark.asyncio async def test_delete_user_sessions_no_sessions(backend: MemoryBackend) -> None: """delete_user_sessions returns 0 when the user has no sessions.""" manager = SessionManager(backend, SessionConfig(secret_key="a" * 32)) @@ -624,7 +595,6 @@ async def test_delete_user_sessions_no_sessions(backend: MemoryBackend) -> None: assert count == 0 -@pytest.mark.asyncio async def test_clear_expired_sessions_none_expired(backend: MemoryBackend) -> None: """clear_expired_sessions returns 0 when no sessions are expired.""" config = SessionConfig(secret_key="a" * 32, session_ttl=3600) @@ -637,7 +607,6 @@ async def test_clear_expired_sessions_none_expired(backend: MemoryBackend) -> No assert count == 0 -@pytest.mark.asyncio async def test_load_session_by_key_invalid_json(backend: MemoryBackend) -> None: """_load_session_by_key returns None for corrupt (non-JSON) session data.""" config = SessionConfig(secret_key="a" * 32) @@ -650,7 +619,6 @@ async def test_load_session_by_key_invalid_json(backend: MemoryBackend) -> None: assert result is None -@pytest.mark.asyncio async def test_ip_binding_with_none_ip_emits_warning( backend: MemoryBackend, caplog: pytest.LogCaptureFixture ) -> None: @@ -670,7 +638,6 @@ async def test_ip_binding_with_none_ip_emits_warning( assert any("ip_binding" in msg for msg in caplog.messages) -@pytest.mark.asyncio async def test_user_agent_binding_with_none_ua_emits_warning( backend: MemoryBackend, caplog: pytest.LogCaptureFixture ) -> None: @@ -690,7 +657,6 @@ async def test_user_agent_binding_with_none_ua_emits_warning( assert any("user_agent_binding" in msg for msg in caplog.messages) -@pytest.mark.asyncio async def test_session_scans_ignore_keys_outside_the_session_prefix( backend: MemoryBackend, ) -> None: @@ -709,7 +675,6 @@ async def test_session_scans_ignore_keys_outside_the_session_prefix( assert await backend.get("cache:unrelated") is not None -@pytest.mark.asyncio async def test_session_sweeps_are_no_ops_without_key_enumeration() -> None: """Memcached cannot list keys, so the bulk operations yield nothing. @@ -729,7 +694,6 @@ async def get_all_keys(self) -> list[str]: assert await manager.clear_expired_sessions() == 0 -@pytest.mark.asyncio async def test_delete_user_sessions_covers_every_session_of_that_user() -> None: """The sweep has to keep going after the first match, and skip other users.""" config = SessionConfig(secret_key="a" * 32) @@ -744,7 +708,6 @@ async def test_delete_user_sessions_covers_every_session_of_that_user() -> None: assert await manager.get_session(other_token) is not None -@pytest.mark.asyncio async def test_session_sweeps_skip_entries_they_cannot_read() -> None: """A key under the prefix that does not decode must be stepped over. @@ -765,7 +728,6 @@ async def test_session_sweeps_skip_entries_they_cannot_read() -> None: assert await manager.delete_user_sessions("u1") == 1 -@pytest.mark.asyncio @pytest.mark.parametrize("status", [SessionStatus.INVALIDATED, SessionStatus.EXPIRED]) async def test_clear_expired_sessions_removes_sessions_no_longer_active( status: SessionStatus, @@ -784,7 +746,6 @@ async def test_clear_expired_sessions_removes_sessions_no_longer_active( assert await manager.get_session(active_token) is not None -@pytest.mark.asyncio async def test_session_sweeps_delete_in_one_batch() -> None: """Both sweeps hand every matching key to a single delete_many (#165).""" diff --git a/tests/session/test_middleware.py b/tests/session/test_middleware.py index c8d9bae..7fda759 100644 --- a/tests/session/test_middleware.py +++ b/tests/session/test_middleware.py @@ -77,7 +77,6 @@ def test_construction_warns_and_points_to_the_replacement( TestClient(app).get("/") -@pytest.mark.asyncio async def test_a_header_token_loads_the_session( manager: SessionManager, config: SessionConfig ) -> None: @@ -90,7 +89,6 @@ async def test_a_header_token_loads_the_session( assert config.header_name not in response.headers -@pytest.mark.asyncio async def test_a_bearer_token_loads_the_session( manager: SessionManager, config: SessionConfig ) -> None: @@ -125,7 +123,6 @@ def test_a_missing_or_invalid_token_loads_no_session( assert config.header_name not in response.headers -@pytest.mark.asyncio async def test_an_expired_session_is_not_loaded( manager: SessionManager, config: SessionConfig ) -> None: @@ -140,7 +137,6 @@ async def test_an_expired_session_is_not_loaded( assert response.json() == {"session_id": None, "user": None} -@pytest.mark.asyncio async def test_config_defaults_to_the_managers() -> None: config = SessionConfig(secret_key="a" * 32, header_name="X-Custom-Session") manager = SessionManager(MemoryBackend(), config) @@ -152,7 +148,6 @@ async def test_config_defaults_to_the_managers() -> None: assert response.json()["user"] == "u1" -@pytest.mark.asyncio async def test_an_explicit_config_overrides_the_managers( manager: SessionManager, ) -> None: @@ -168,7 +163,6 @@ async def test_an_explicit_config_overrides_the_managers( ) -@pytest.mark.asyncio async def test_the_manager_defaults_to_the_proxy( manager: SessionManager, config: SessionConfig ) -> None: @@ -181,7 +175,6 @@ async def test_the_manager_defaults_to_the_proxy( assert response.json()["user"] == "u1" -@pytest.mark.asyncio @pytest.mark.parametrize( ("peer", "loaded"), [("203.0.113.7", True), ("198.51.100.1", False)] ) @@ -200,7 +193,6 @@ async def test_ip_binding_checks_the_peer_address( assert (response.json()["user"] == "u1") is loaded -@pytest.mark.asyncio @pytest.mark.parametrize( ("forwarded_for", "loaded"), [("203.0.113.7", True), ("198.51.100.1", False)] ) @@ -224,7 +216,6 @@ async def test_ip_binding_uses_the_forwarded_address_behind_a_trusted_proxy( assert (response.json()["user"] == "u1") is loaded -@pytest.mark.asyncio @pytest.mark.parametrize( ("user_agent", "loaded"), [("App/1.0", True), ("Other/2.0", False)] ) @@ -245,7 +236,6 @@ async def test_user_agent_binding_checks_the_request_user_agent( assert (response.json()["user"] == "u1") is loaded -@pytest.mark.asyncio async def test_a_rotated_session_id_is_sent_back_as_a_new_token( manager: SessionManager, config: SessionConfig ) -> None: @@ -263,7 +253,6 @@ async def test_a_rotated_session_id_is_sent_back_as_a_new_token( assert old["user"] is None -@pytest.mark.asyncio async def test_sliding_expiration_sends_the_renewed_token() -> None: """The refreshed token goes back in the response header, with a later expiry.""" slide_config = SessionConfig( diff --git a/tests/session/test_starlette_middleware.py b/tests/session/test_starlette_middleware.py index 335cab8..3d2f973 100644 --- a/tests/session/test_starlette_middleware.py +++ b/tests/session/test_starlette_middleware.py @@ -104,7 +104,6 @@ async def set_route(request: Request) -> dict[str, bool]: assert "domain=example.com" in set_cookie -@pytest.mark.asyncio async def test_call_passes_through_non_http_scope() -> None: """Non-http/websocket scopes (e.g. lifespan) must bypass session handling entirely.""" config = SessionConfig(secret_key="a" * 32) @@ -167,7 +166,6 @@ async def test_route() -> dict[str, bool]: assert "set-cookie" not in response.headers -@pytest.mark.asyncio async def test_no_cookie_dict_mutated_creates_session( manager: SessionManager, config: SessionConfig ) -> None: @@ -220,7 +218,6 @@ async def roundtrip_route(request: Request) -> dict[str, bool]: assert "set-cookie" not in response.headers -@pytest.mark.asyncio async def test_valid_cookie_dict_mutated_merges_data( manager: SessionManager, config: SessionConfig ) -> None: @@ -250,7 +247,6 @@ async def add_route(request: Request) -> dict[str, bool]: assert reloaded.data == {"a": 1, "b": 2} -@pytest.mark.asyncio async def test_valid_cookie_sliding_expiration_refreshes_cookie_without_mutation( manager: SessionManager, config: SessionConfig ) -> None: @@ -292,7 +288,6 @@ async def noop_route() -> dict[str, bool]: assert renewed.expires_at > shortened_expiry -@pytest.mark.asyncio async def test_valid_cookie_cleared_deletes_backend_session( manager: SessionManager, config: SessionConfig ) -> None: @@ -346,7 +341,6 @@ async def set_route(request: Request) -> dict[str, bool]: assert "set-cookie" in response.headers -@pytest.mark.asyncio async def test_expired_cookie_starts_fresh_session( manager: SessionManager, config: SessionConfig ) -> None: @@ -373,7 +367,6 @@ async def test_route(request: Request) -> dict[str, bool]: assert response.json() == {"has_data": False} -@pytest.mark.asyncio async def test_ip_binding_mismatch_starts_fresh_session( config: SessionConfig, ) -> None: @@ -505,7 +498,6 @@ def test_get_client_ip_none(config: SessionConfig) -> None: assert get_client_ip(connection, config) is None -@pytest.mark.asyncio async def test_get_session_dependency_works_under_starlette_middleware( manager: SessionManager, config: SessionConfig ) -> None: @@ -540,7 +532,6 @@ async def me_route(session=Depends(get_session)): assert authenticated.json() == {"user_id": "cookie-user"} -@pytest.mark.asyncio async def test_header_token_takes_priority_under_starlette_middleware( manager: SessionManager, config: SessionConfig ) -> None: @@ -576,7 +567,6 @@ async def me_route(session=Depends(get_session)): assert header_wins.json() == {"user_id": "header-user"} -@pytest.mark.asyncio async def test_header_source_renewal_uses_response_header_not_cookie( manager: SessionManager, config: SessionConfig ) -> None: @@ -613,7 +603,6 @@ async def noop_route() -> dict[str, bool]: assert renewed.expires_at > shortened_expiry -@pytest.mark.asyncio async def test_header_source_modify_persists_without_cookie( manager: SessionManager, config: SessionConfig ) -> None: @@ -645,7 +634,6 @@ async def add_route(request: Request) -> dict[str, bool]: assert reloaded.data == {"a": 1, "b": 2} -@pytest.mark.asyncio async def test_header_source_invalid_token_new_session_via_header( manager: SessionManager, config: SessionConfig ) -> None: @@ -685,7 +673,6 @@ async def app(scope, receive, send): SessionMiddleware(app, manager, config) -@pytest.mark.asyncio async def test_header_source_cleared_session_is_deleted_without_a_cookie( manager: SessionManager, config: SessionConfig ) -> None: @@ -748,7 +735,6 @@ async def _shorten_expiry(manager: SessionManager, token: str) -> None: @pytest.mark.parametrize("write_data", [True, False]) @pytest.mark.parametrize("sliding", [True, False]) -@pytest.mark.asyncio async def test_regenerated_session_id_is_sent_as_cookie( manager: SessionManager, config: SessionConfig, write_data: bool, sliding: bool ) -> None: @@ -782,7 +768,6 @@ async def test_regenerated_session_id_is_sent_as_cookie( @pytest.mark.parametrize("write_data", [True, False]) @pytest.mark.parametrize("sliding", [True, False]) -@pytest.mark.asyncio async def test_regenerated_session_id_is_sent_in_the_header( manager: SessionManager, config: SessionConfig, write_data: bool, sliding: bool ) -> None: @@ -805,7 +790,6 @@ async def test_regenerated_session_id_is_sent_in_the_header( @pytest.mark.filterwarnings("ignore::DeprecationWarning") @pytest.mark.parametrize("sliding", [True, False]) -@pytest.mark.asyncio async def test_deprecated_middleware_sends_regenerated_token( manager: SessionManager, config: SessionConfig, sliding: bool ) -> None: @@ -849,7 +833,6 @@ async def login(request: Request): return app -@pytest.mark.asyncio async def test_rotate_session_id_defeats_a_planted_cookie( manager: SessionManager, config: SessionConfig ) -> None: @@ -892,7 +875,6 @@ def test_rotate_session_id_without_a_session( assert config.cookie_name in client.cookies -@pytest.mark.asyncio async def test_rotate_session_id_over_the_header( manager: SessionManager, config: SessionConfig ) -> None: @@ -937,7 +919,6 @@ async def untouched_route() -> dict[str, bool]: return app -@pytest.mark.asyncio async def test_header_token_varies_on_the_token_header_not_cookie( manager: SessionManager, config: SessionConfig ) -> None: @@ -950,7 +931,6 @@ async def test_header_token_varies_on_the_token_header_not_cookie( assert _vary(response) == {config.header_name.lower()} -@pytest.mark.asyncio async def test_bearer_token_varies_on_every_header_consulted( manager: SessionManager, config: SessionConfig ) -> None: @@ -963,7 +943,6 @@ async def test_bearer_token_varies_on_every_header_consulted( assert _vary(response) == {config.header_name.lower(), "authorization"} -@pytest.mark.asyncio async def test_cookie_token_varies_on_cookie_and_the_headers_checked_first( manager: SessionManager, config: SessionConfig ) -> None: @@ -1021,7 +1000,6 @@ async def clear_then_write_route(request: Request) -> dict[str, bool]: return app -@pytest.mark.asyncio @pytest.mark.parametrize("transport", ["cookie", "header"]) async def test_clear_logs_out_a_session_with_empty_data( manager: SessionManager, config: SessionConfig, transport: str @@ -1052,7 +1030,6 @@ async def test_clear_logs_out_a_session_with_empty_data( await manager.get_session(token) -@pytest.mark.asyncio async def test_popping_the_last_key_keeps_a_user_logged_in( manager: SessionManager, config: SessionConfig ) -> None: @@ -1071,7 +1048,6 @@ async def test_popping_the_last_key_keeps_a_user_logged_in( assert kept.data == {} -@pytest.mark.asyncio async def test_popping_the_last_key_deletes_an_anonymous_session( manager: SessionManager, config: SessionConfig ) -> None: @@ -1088,7 +1064,6 @@ async def test_popping_the_last_key_deletes_an_anonymous_session( await manager.get_session(token) -@pytest.mark.asyncio @pytest.mark.parametrize("transport", ["cookie", "header"]) async def test_writing_after_clear_starts_a_new_anonymous_session( manager: SessionManager, config: SessionConfig, transport: str @@ -1118,7 +1093,6 @@ async def test_writing_after_clear_starts_a_new_anonymous_session( assert fresh.data == {"flash": "signed out"} -@pytest.mark.asyncio async def test_require_user_session_rejects_anonymous_sessions( manager: SessionManager, config: SessionConfig ) -> None: diff --git a/tests/session/test_token_serializers.py b/tests/session/test_token_serializers.py index b33e98d..51549b7 100644 --- a/tests/session/test_token_serializers.py +++ b/tests/session/test_token_serializers.py @@ -298,7 +298,8 @@ def test_jwt_serializer_round_trips_hmac_algorithms(algorithm: str) -> None: """The HMAC algorithms work end to end with real PyJWT.""" pytest.importorskip("jwt") config = SessionConfig( - secret_key=SecretStr("a" * 32), + # Long enough for HS512, so no algorithm warns about a short key. + secret_key=SecretStr("a" * 64), token_format="jwt", jwt_algorithm=algorithm, ) diff --git a/tests/state/test_manager.py b/tests/state/test_manager.py index c104f39..ce8a1b0 100644 --- a/tests/state/test_manager.py +++ b/tests/state/test_manager.py @@ -79,7 +79,6 @@ async def state_manager( backend.stop_cleanup() -@pytest.mark.asyncio async def test_create_state_basic(state_manager: StateManager) -> None: """Test creating a basic OAuth state.""" state = await state_manager.create_state() @@ -89,7 +88,6 @@ async def test_create_state_basic(state_manager: StateManager) -> None: assert len(state) > 0 -@pytest.mark.asyncio async def test_create_state_with_metadata(state_manager: StateManager) -> None: """Test creating OAuth state with metadata.""" metadata = { @@ -108,7 +106,6 @@ async def test_create_state_with_metadata(state_manager: StateManager) -> None: assert retrieved_metadata == metadata -@pytest.mark.asyncio async def test_create_state_with_custom_ttl(state_manager: StateManager) -> None: """Test creating OAuth state with custom TTL.""" custom_ttl = 1800 # 30 minutes @@ -120,7 +117,6 @@ async def test_create_state_with_custom_ttl(state_manager: StateManager) -> None assert is_valid is True -@pytest.mark.asyncio async def test_consume_state(state_manager: StateManager) -> None: """Test consuming a valid OAuth state.""" state = await state_manager.create_state() @@ -139,7 +135,6 @@ async def test_consume_state(state_manager: StateManager) -> None: assert is_valid is False -@pytest.mark.asyncio async def test_consume_state_with_different_manager( memory_backend: MemoryBackend, ) -> None: @@ -161,7 +156,6 @@ async def test_consume_state_with_different_manager( assert is_valid is False -@pytest.mark.asyncio async def test_consume_state_with_metadata(state_manager: StateManager) -> None: """Test consuming state and retrieving its metadata.""" metadata = { @@ -177,14 +171,12 @@ async def test_consume_state_with_metadata(state_manager: StateManager) -> None: assert state_data.metadata == metadata -@pytest.mark.asyncio async def test_consume_invalid_state(state_manager: StateManager) -> None: """Test consuming an invalid state raises InvalidStateError.""" with pytest.raises(InvalidStateError, match="Invalid or expired state"): await state_manager.consume_state("invalid_state_string") -@pytest.mark.asyncio async def test_consume_state_after_backend_ttl_is_invalid( memory_backend: MemoryBackend, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -204,7 +196,6 @@ async def test_consume_state_after_backend_ttl_is_invalid( await manager.consume_state(state) -@pytest.mark.asyncio async def test_validate_state(state_manager: StateManager) -> None: """Test validating a state without consuming it.""" state = await state_manager.create_state() @@ -218,14 +209,12 @@ async def test_validate_state(state_manager: StateManager) -> None: assert is_valid_again is True -@pytest.mark.asyncio async def test_validate_invalid_state(state_manager: StateManager) -> None: """Test validating an invalid state.""" is_valid = await state_manager.validate_state("invalid_state") assert is_valid is False -@pytest.mark.asyncio async def test_get_state_metadata_valid(state_manager: StateManager) -> None: """Test retrieving metadata from a valid state.""" metadata = {"key": "value", "nested": {"data": 123}} @@ -237,14 +226,12 @@ async def test_get_state_metadata_valid(state_manager: StateManager) -> None: assert retrieved == metadata -@pytest.mark.asyncio async def test_get_state_metadata_invalid(state_manager: StateManager) -> None: """Test retrieving metadata from an invalid state.""" retrieved = await state_manager.get_state_metadata("invalid_state") assert retrieved is None -@pytest.mark.asyncio async def test_delete_state(state_manager: StateManager) -> None: """Test manually deleting a state.""" state = await state_manager.create_state() @@ -262,7 +249,6 @@ async def test_delete_state(state_manager: StateManager) -> None: assert is_valid_after is False -@pytest.mark.asyncio async def test_multiple_states_independent(state_manager: StateManager) -> None: """Test that multiple states are independent.""" metadata1 = {"user_id": "user1"} @@ -291,7 +277,6 @@ async def test_multiple_states_independent(state_manager: StateManager) -> None: assert is_valid2 is True -@pytest.mark.asyncio async def test_state_expiry_information(state_manager: StateManager) -> None: """Test that state data contains correct expiry information.""" ttl = 3600 @@ -308,7 +293,6 @@ async def test_state_expiry_information(state_manager: StateManager) -> None: assert abs(time_diff - ttl) < 5 # Allow 5 seconds tolerance -@pytest.mark.asyncio async def test_state_manager_custom_prefix(memory_backend: MemoryBackend) -> None: """Test StateManager with custom key prefix.""" BackendProxy.set(memory_backend) @@ -326,7 +310,6 @@ async def test_state_manager_custom_prefix(memory_backend: MemoryBackend) -> Non assert is_valid_other is False -@pytest.mark.asyncio async def test_state_reuse_prevention(state_manager: StateManager) -> None: """Test that consumed states cannot be reused.""" state = await state_manager.create_state() @@ -339,7 +322,6 @@ async def test_state_reuse_prevention(state_manager: StateManager) -> None: await state_manager.consume_state(state) -@pytest.mark.asyncio async def test_get_state_metadata_after_expire( state_manager: StateManager, clock: Clock ) -> None: @@ -354,7 +336,6 @@ async def test_get_state_metadata_after_expire( assert retrieved is None -@pytest.mark.asyncio async def test_consume_state_with_invalid_json(state_manager: StateManager) -> None: """Test consuming state when backend returns invalid JSON.""" # Directly set invalid JSON in backend @@ -368,7 +349,6 @@ async def test_consume_state_with_invalid_json(state_manager: StateManager) -> N await state_manager.consume_state("bad_state") -@pytest.mark.asyncio async def test_get_metadata_with_invalid_json(state_manager: StateManager) -> None: """Test retrieving metadata when backend returns invalid JSON.""" # Directly set invalid JSON in backend @@ -382,7 +362,6 @@ async def test_get_metadata_with_invalid_json(state_manager: StateManager) -> No assert retrieved is None -@pytest.mark.asyncio async def test_validate_state_with_invalid_json(state_manager: StateManager) -> None: """Test validating state when backend returns invalid JSON.""" # Directly set invalid JSON in backend @@ -396,7 +375,6 @@ async def test_validate_state_with_invalid_json(state_manager: StateManager) -> assert is_valid is False -@pytest.mark.asyncio async def test_create_state_empty_metadata(state_manager: StateManager) -> None: """Test creating state with empty metadata.""" state = await state_manager.create_state(metadata={}) @@ -406,7 +384,6 @@ async def test_create_state_empty_metadata(state_manager: StateManager) -> None: assert metadata == {} -@pytest.mark.asyncio async def test_state_with_complex_nested_metadata(state_manager: StateManager) -> None: """Test state with complex nested metadata structures.""" complex_metadata = { @@ -427,7 +404,6 @@ async def test_state_with_complex_nested_metadata(state_manager: StateManager) - assert retrieved == complex_metadata -@pytest.mark.asyncio async def test_get_metadata_with_missing_expiry(state_manager: StateManager) -> None: """A stored state without expires_at yields no metadata.""" state = "test_state" @@ -449,7 +425,6 @@ async def test_get_metadata_with_missing_expiry(state_manager: StateManager) -> assert await state_manager.backend.get(cache_key) is not None -@pytest.mark.asyncio async def test_validate_state_with_missing_expiry(state_manager: StateManager) -> None: """A stored state without expires_at does not validate.""" state = "test_state" @@ -471,7 +446,6 @@ async def test_validate_state_with_missing_expiry(state_manager: StateManager) - assert await state_manager.backend.get(cache_key) is not None -@pytest.mark.asyncio async def test_validate_state_with_invalid_expiry_format( state_manager: StateManager, ) -> None: @@ -496,7 +470,6 @@ async def test_validate_state_with_invalid_expiry_format( assert is_valid is False -@pytest.mark.asyncio async def test_get_metadata_with_invalid_expiry_format( state_manager: StateManager, ) -> None: @@ -521,7 +494,6 @@ async def test_get_metadata_with_invalid_expiry_format( assert retrieved is None -@pytest.mark.asyncio async def test_consume_state_with_missing_expiry(state_manager: StateManager) -> None: """Test consuming state when state data is missing expiry.""" state = "test_state" @@ -542,7 +514,6 @@ async def test_consume_state_with_missing_expiry(state_manager: StateManager) -> await state_manager.consume_state(state) -@pytest.mark.asyncio async def test_consume_state_with_non_string_content( state_manager: StateManager, ) -> None: @@ -558,7 +529,6 @@ async def test_consume_state_with_non_string_content( await state_manager.consume_state("bad_state") -@pytest.mark.asyncio async def test_validate_state_with_non_string_content( state_manager: StateManager, ) -> None: @@ -572,7 +542,6 @@ async def test_validate_state_with_non_string_content( assert is_valid is False -@pytest.mark.asyncio async def test_get_metadata_with_non_string_content( state_manager: StateManager, ) -> None: @@ -586,7 +555,6 @@ async def test_get_metadata_with_non_string_content( assert retrieved is None -@pytest.mark.asyncio async def test_get_metadata_with_non_dict_metadata(state_manager: StateManager) -> None: """Test retrieving metadata when metadata is not a dict.""" state = "test_state" @@ -609,7 +577,6 @@ async def test_get_metadata_with_non_dict_metadata(state_manager: StateManager) assert retrieved is None -@pytest.mark.asyncio async def test_consume_state_with_bad_expiry_date(state_manager: StateManager) -> None: """Test consuming state when expiry date has invalid format.""" state = "test_state" @@ -637,7 +604,6 @@ async def test_consume_state_with_bad_expiry_date(state_manager: StateManager) - # --------------------------------------------------------------------------- -@pytest.mark.asyncio async def test_delete_state_nonexistent_returns_false( state_manager: StateManager, ) -> None: @@ -646,7 +612,6 @@ async def test_delete_state_nonexistent_returns_false( assert result is False -@pytest.mark.asyncio async def test_delete_state_existing_returns_true( state_manager: StateManager, ) -> None: @@ -659,7 +624,6 @@ async def test_delete_state_existing_returns_true( assert is_valid is False -@pytest.mark.asyncio async def test_delete_state_idempotent_returns_false_on_second_call( state_manager: StateManager, ) -> None: @@ -669,7 +633,6 @@ async def test_delete_state_idempotent_returns_false_on_second_call( assert await state_manager.delete_state(state) is False -@pytest.mark.asyncio async def test_state_manager_accepts_explicit_backend( memory_backend: MemoryBackend, ) -> None: @@ -686,7 +649,6 @@ async def test_state_manager_accepts_explicit_backend( assert await manager.validate_state(state) is True -@pytest.mark.asyncio async def test_state_manager_falls_back_to_backend_proxy( memory_backend: MemoryBackend, ) -> None: @@ -712,7 +674,6 @@ def test_state_manager_raises_when_no_backend_configured() -> None: StateManager() -@pytest.mark.asyncio async def test_consume_state_has_exactly_one_winner_under_concurrency( state_manager: StateManager, ) -> None: @@ -733,7 +694,6 @@ async def test_consume_state_has_exactly_one_winner_under_concurrency( assert winners[0].metadata == {"n": 1} -@pytest.mark.asyncio async def test_consume_state_past_wall_clock_expiry_removes_the_entry( state_manager: StateManager, ) -> None: @@ -765,7 +725,6 @@ async def test_consume_state_past_wall_clock_expiry_removes_the_entry( assert await state_manager.validate_state(state) is False -@pytest.mark.asyncio async def test_peeking_a_state_past_its_wall_clock_expiry_treats_it_as_gone( state_manager: StateManager, ) -> None: @@ -790,7 +749,6 @@ def _state_records(caplog: pytest.LogCaptureFixture) -> list[logging.LogRecord]: return [r for r in caplog.records if r.name == "fastapi_cachex.state.manager"] -@pytest.mark.asyncio async def test_logs_never_contain_the_raw_state( memory_backend: MemoryBackend, caplog: pytest.LogCaptureFixture ) -> None: @@ -815,7 +773,6 @@ async def test_logs_never_contain_the_raw_state( assert hashlib.sha256(state.encode()).hexdigest()[:12] in caplog.text -@pytest.mark.asyncio async def test_unknown_state_is_not_logged_as_warning( memory_backend: MemoryBackend, caplog: pytest.LogCaptureFixture ) -> None: @@ -842,7 +799,6 @@ async def test_unknown_state_is_not_logged_as_warning( assert all(r.levelno < logging.WARNING for r in _state_records(caplog)) -@pytest.mark.asyncio @pytest.mark.parametrize("operation", ["consume", "validate", "metadata"]) async def test_malformed_state_data_is_logged_once_without_the_state( memory_backend: MemoryBackend, @@ -875,7 +831,6 @@ async def test_malformed_state_data_is_logged_once_without_the_state( assert state not in caplog.text -@pytest.mark.asyncio async def test_bound_state_is_accepted_with_its_binding( state_manager: StateManager, ) -> None: @@ -888,7 +843,6 @@ async def test_bound_state_is_accepted_with_its_binding( assert data.binding_hash == hashlib.sha256(b"nonce-a").hexdigest() -@pytest.mark.asyncio @pytest.mark.parametrize("binding", ["nonce-b", None]) async def test_bound_state_is_rejected_for_another_client( state_manager: StateManager, binding: str | None @@ -907,7 +861,6 @@ async def test_bound_state_is_rejected_for_another_client( await state_manager.consume_state(state, binding="nonce-a") -@pytest.mark.asyncio async def test_unbound_state_is_rejected_with_a_binding( state_manager: StateManager, ) -> None: @@ -918,14 +871,12 @@ async def test_unbound_state_is_rejected_with_a_binding( await state_manager.consume_state(state, binding="nonce-a") -@pytest.mark.asyncio async def test_empty_binding_is_rejected(state_manager: StateManager) -> None: """An empty binding (a missing cookie read as "") would bind everyone alike.""" with pytest.raises(ValueError, match="binding must not be empty"): await state_manager.create_state(binding="") -@pytest.mark.asyncio async def test_binding_is_not_stored_in_plain_text( memory_backend: MemoryBackend, ) -> None: diff --git a/tests/test_cache.py b/tests/test_cache.py index f6db67c..4350c69 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,4 +1,3 @@ -import asyncio import threading from collections.abc import AsyncGenerator from functools import partial @@ -685,7 +684,7 @@ async def no_ttl_endpoint(): backend.stop_cleanup() -def test_without_a_ttl_an_entry_left_by_an_older_version_is_ignored(): +async def test_without_a_ttl_an_entry_left_by_an_older_version_is_ignored(): """Entries 0.3.7 stored without expiry are neither served nor refreshed (#110).""" legacy_app = FastAPI() backend = MemoryBackend() @@ -699,7 +698,7 @@ async def legacy_endpoint(): legacy_client = TestClient(legacy_app) key = "GET|||testserver|||/legacy|||" stale = CacheEntry(fingerprint='W/"old"', content=b"old", media_type="text/plain") - asyncio.run(backend.set(key, stale)) + await backend.set(key, stale) r = legacy_client.get("/legacy", headers={"If-None-Match": 'W/"old"'}) assert r.status_code == 200 diff --git a/tests/test_cache_error_paths.py b/tests/test_cache_error_paths.py index 5f3a8bf..53a898a 100644 --- a/tests/test_cache_error_paths.py +++ b/tests/test_cache_error_paths.py @@ -39,7 +39,6 @@ async def _receive() -> dict[str, Any]: return {"type": "http.request", "body": b"", "more_body": False} -@pytest.mark.asyncio async def test_missing_request_raises_request_not_found(): """Calling the wrapper without the injected request is an error.""" @@ -51,7 +50,6 @@ async def handler() -> dict[str, bool]: await handler() -@pytest.mark.asyncio async def test_declared_request_left_unbound_raises_request_not_found(): """The `found_request` branch reads the parameter instead of popping it.""" @@ -63,7 +61,6 @@ async def handler(request: Request) -> dict[str, bool]: await handler(request=None) -@pytest.mark.asyncio async def test_missing_route_in_scope_raises_cachex_error(): """Building a response needs the route's `response_class`.""" app = FastAPI() @@ -80,7 +77,6 @@ async def handler() -> dict[str, bool]: await handler(__cachex_request=Request(scope, _receive)) -@pytest.mark.asyncio async def test_response_returning_handler_does_not_need_the_route(): """A handler that already returns a `Response` never looks the route up.""" app = FastAPI() diff --git a/tests/test_cache_invalidate.py b/tests/test_cache_invalidate.py index 7043f7f..13b6f76 100644 --- a/tests/test_cache_invalidate.py +++ b/tests/test_cache_invalidate.py @@ -1,6 +1,5 @@ """Tests for the cache.invalidate() helper.""" -import asyncio from collections.abc import Generator import pytest @@ -46,7 +45,7 @@ def _build_request(path: str, host: str = "testserver") -> Request: return Request(scope) -def test_invalidate_forces_re_execution_on_next_request() -> None: +async def test_invalidate_forces_re_execution_on_next_request() -> None: response1 = client.get("/invalidate-target") assert response1.status_code == 200 assert response1.json() == {"calls": 1} @@ -56,7 +55,7 @@ def test_invalidate_forces_re_execution_on_next_request() -> None: assert response2.status_code == 200 assert response2.json() == {"calls": 1} - removed = asyncio.run(invalidate(_build_request("/invalidate-target"))) + removed = await invalidate(_build_request("/invalidate-target")) assert removed is True # Cache miss after invalidation: handler runs again. @@ -65,28 +64,25 @@ def test_invalidate_forces_re_execution_on_next_request() -> None: assert response3.json() == {"calls": 2} -def test_invalidate_returns_false_for_missing_key() -> None: - removed = asyncio.run(invalidate(_build_request("/does-not-exist"))) +async def test_invalidate_returns_false_for_missing_key() -> None: + removed = await invalidate(_build_request("/does-not-exist")) assert removed is False -def test_invalidate_returns_false_when_no_backend_configured() -> None: +async def test_invalidate_returns_false_when_no_backend_configured() -> None: BackendProxy.set(None) - removed = asyncio.run(invalidate(_build_request("/invalidate-target"))) + removed = await invalidate(_build_request("/invalidate-target")) assert removed is False -def test_invalidate_with_custom_key_builder() -> None: +async def test_invalidate_with_custom_key_builder() -> None: def custom_key_builder(request: Request) -> str: return f"custom:{request.url.path}" - async def _run() -> None: - backend = BackendProxy.get() - await backend.set("custom:/x", CacheEntry(fingerprint="e", content=b"v")) + backend = BackendProxy.get() + await backend.set("custom:/x", CacheEntry(fingerprint="e", content=b"v")) - removed = await invalidate(_build_request("/x"), key_builder=custom_key_builder) + removed = await invalidate(_build_request("/x"), key_builder=custom_key_builder) - assert removed is True - assert await backend.get("custom:/x") is None - - asyncio.run(_run()) + assert removed is True + assert await backend.get("custom:/x") is None diff --git a/tests/test_cache_manager.py b/tests/test_cache_manager.py index 3952608..59e7a09 100644 --- a/tests/test_cache_manager.py +++ b/tests/test_cache_manager.py @@ -73,7 +73,6 @@ async def cache_manager(request: Any) -> AsyncGenerator[CacheManager, Any]: # --- Round-trip serialization ------------------------------------------------- -@pytest.mark.asyncio async def test_set_get_roundtrip_dict(cache_manager: CacheManager) -> None: """A dict value round-trips through set/get.""" value = {"a": 1, "b": [1, 2, 3]} @@ -81,7 +80,6 @@ async def test_set_get_roundtrip_dict(cache_manager: CacheManager) -> None: assert await cache_manager.get("key") == value -@pytest.mark.asyncio async def test_set_get_roundtrip_list(cache_manager: CacheManager) -> None: """A list value round-trips through set/get.""" value = [1, "two", 3.0, None] @@ -89,21 +87,18 @@ async def test_set_get_roundtrip_list(cache_manager: CacheManager) -> None: assert await cache_manager.get("key") == value -@pytest.mark.asyncio async def test_set_get_roundtrip_str(cache_manager: CacheManager) -> None: """A plain string value round-trips through set/get.""" await cache_manager.set("key", "hello") assert await cache_manager.get("key") == "hello" -@pytest.mark.asyncio async def test_set_get_roundtrip_int(cache_manager: CacheManager) -> None: """An int value round-trips through set/get.""" await cache_manager.set("key", 42) assert await cache_manager.get("key") == 42 -@pytest.mark.asyncio async def test_set_get_roundtrip_bool(cache_manager: CacheManager) -> None: """Bool values round-trip through set/get without collapsing to 0/1.""" await cache_manager.set("key_true", value=True) @@ -112,7 +107,6 @@ async def test_set_get_roundtrip_bool(cache_manager: CacheManager) -> None: assert await cache_manager.get("key_false") is False -@pytest.mark.asyncio async def test_set_get_roundtrip_none_value(cache_manager: CacheManager) -> None: """Explicitly caching None as a value is distinguishable from a cache miss.""" await cache_manager.set("key", None) @@ -123,13 +117,11 @@ async def test_set_get_roundtrip_none_value(cache_manager: CacheManager) -> None # --- Missing keys / defaults --------------------------------------------------- -@pytest.mark.asyncio async def test_get_missing_key_returns_default(cache_manager: CacheManager) -> None: """get() on a missing key returns None by default.""" assert await cache_manager.get("nope") is None -@pytest.mark.asyncio async def test_get_missing_key_returns_custom_default( cache_manager: CacheManager, ) -> None: @@ -140,7 +132,6 @@ async def test_get_missing_key_returns_custom_default( # --- get_or_set ----------------------------------------------------------------- -@pytest.mark.asyncio async def test_get_or_set_hit_does_not_call_factory( cache_manager: CacheManager, ) -> None: @@ -159,7 +150,6 @@ def factory() -> str: assert calls == 0 -@pytest.mark.asyncio async def test_get_or_set_miss_calls_sync_factory_and_caches( cache_manager: CacheManager, ) -> None: @@ -178,7 +168,6 @@ def factory() -> dict[str, int]: assert await cache_manager.get("nope") == {"computed": 1} -@pytest.mark.asyncio async def test_get_or_set_miss_calls_async_factory_and_caches( cache_manager: CacheManager, ) -> None: @@ -197,7 +186,6 @@ async def factory() -> str: assert await cache_manager.get("nope") == "async_value" -@pytest.mark.asyncio @pytest.mark.parametrize("wrap", ["lambda", "partial"]) async def test_get_or_set_awaits_a_sync_callable_returning_an_awaitable( cache_manager: CacheManager, wrap: str @@ -215,7 +203,6 @@ async def load_user(user_id: int) -> dict[str, int]: assert await cache_manager.get("user") == {"id": 42} -@pytest.mark.asyncio async def test_get_or_set_honors_ttl_on_created_value( cache_manager: CacheManager, clock: Clock ) -> None: @@ -229,7 +216,6 @@ async def test_get_or_set_honors_ttl_on_created_value( assert await cache_manager.get("key") is None -@pytest.mark.asyncio async def test_get_or_set_treats_corrupted_content_as_miss( memory_backend: MemoryBackend, ) -> None: @@ -248,14 +234,12 @@ async def test_get_or_set_treats_corrupted_content_as_miss( # --- add ------------------------------------------------------------------------ -@pytest.mark.asyncio async def test_add_stores_when_key_is_free(cache_manager: CacheManager) -> None: """add() stores the value and reports it when nothing holds the key.""" assert await cache_manager.add("event:1", {"sent": True}) is True assert await cache_manager.get("event:1") == {"sent": True} -@pytest.mark.asyncio async def test_add_keeps_the_existing_value(cache_manager: CacheManager) -> None: """add() never overwrites: the first value stays and the call reports False.""" await cache_manager.set("event:1", "first") @@ -264,7 +248,6 @@ async def test_add_keeps_the_existing_value(cache_manager: CacheManager) -> None assert await cache_manager.get("event:1") == "first" -@pytest.mark.asyncio async def test_add_concurrent_callers_have_exactly_one_winner( cache_manager: CacheManager, ) -> None: @@ -278,7 +261,6 @@ async def test_add_concurrent_callers_have_exactly_one_winner( assert await cache_manager.get("event:1") == results.index(True) -@pytest.mark.asyncio async def test_add_ttl_expires_the_claim( cache_manager: CacheManager, clock: Clock ) -> None: @@ -292,7 +274,6 @@ async def test_add_ttl_expires_the_claim( assert await cache_manager.get("event:1") == "third" -@pytest.mark.asyncio async def test_add_uses_default_ttl( memory_backend: MemoryBackend, clock: Clock ) -> None: @@ -305,7 +286,6 @@ async def test_add_uses_default_ttl( assert await manager.get("event:1") is None -@pytest.mark.asyncio async def test_add_treats_undecodable_content_as_present( memory_backend: MemoryBackend, ) -> None: @@ -318,7 +298,6 @@ async def test_add_treats_undecodable_content_as_present( assert await manager.get("bad", default="fallback") == "fallback" -@pytest.mark.asyncio async def test_add_non_json_serializable_raises_type_error( cache_manager: CacheManager, ) -> None: @@ -332,7 +311,6 @@ async def test_add_non_json_serializable_raises_type_error( # --- delete / has --------------------------------------------------------------- -@pytest.mark.asyncio async def test_delete_existing_key_returns_true(cache_manager: CacheManager) -> None: """delete() returns True when the key existed.""" await cache_manager.set("key", "value") @@ -340,7 +318,6 @@ async def test_delete_existing_key_returns_true(cache_manager: CacheManager) -> assert await cache_manager.get("key") is None -@pytest.mark.asyncio async def test_delete_nonexistent_key_returns_false( cache_manager: CacheManager, ) -> None: @@ -348,14 +325,12 @@ async def test_delete_nonexistent_key_returns_false( assert await cache_manager.delete("nope") is False -@pytest.mark.asyncio async def test_has_existing_key_true(cache_manager: CacheManager) -> None: """has() returns True for an existing key.""" await cache_manager.set("key", "value") assert await cache_manager.has("key") is True -@pytest.mark.asyncio async def test_has_missing_key_false(cache_manager: CacheManager) -> None: """has() returns False for a missing key.""" assert await cache_manager.has("nope") is False @@ -364,7 +339,6 @@ async def test_has_missing_key_false(cache_manager: CacheManager) -> None: # --- TTL -------------------------------------------------------------------------- -@pytest.mark.asyncio 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) @@ -375,7 +349,6 @@ async def test_ttl_expiry(cache_manager: CacheManager, clock: Clock) -> None: assert await cache_manager.get("key") is None -@pytest.mark.asyncio async def test_default_ttl_used_when_not_specified( memory_backend: MemoryBackend, clock: Clock ) -> None: @@ -391,7 +364,6 @@ async def test_default_ttl_used_when_not_specified( assert await manager.get("key") is None -@pytest.mark.asyncio async def test_explicit_ttl_overrides_default_ttl( memory_backend: MemoryBackend, clock: Clock ) -> None: @@ -408,13 +380,11 @@ async def test_explicit_ttl_overrides_default_ttl( # --- Key prefixing ------------------------------------------------------------ -@pytest.mark.asyncio async def test_default_key_prefix_is_cache_colon() -> None: """The default key_prefix is 'cache:'.""" assert CacheManager().key_prefix == "cache:" -@pytest.mark.asyncio async def test_key_prefix_isolation(memory_backend: MemoryBackend) -> None: """Two managers with different key_prefix values don't see each other's keys.""" manager_a = CacheManager(backend=memory_backend, key_prefix="a:") @@ -429,7 +399,6 @@ async def test_key_prefix_isolation(memory_backend: MemoryBackend) -> None: # --- clear / clear_prefix ------------------------------------------------------ -@pytest.mark.asyncio async def test_clear_prefix_removes_only_matching_keys( memory_backend: MemoryBackend, ) -> None: @@ -449,7 +418,6 @@ async def test_clear_prefix_removes_only_matching_keys( assert await memory_backend.get("unrelated:key") is not None -@pytest.mark.asyncio async def test_clear_prefix_does_not_count_expired_keys( cache_manager: CacheManager, ) -> None: @@ -467,7 +435,6 @@ async def test_clear_prefix_does_not_count_expired_keys( assert await cache_manager.clear_prefix() == 1 -@pytest.mark.asyncio async def test_clear_prefix_with_subprefix_argument( memory_backend: MemoryBackend, ) -> None: @@ -485,7 +452,6 @@ async def test_clear_prefix_with_subprefix_argument( assert await manager.get("other:1") == "carol" -@pytest.mark.asyncio async def test_clear_removes_all_manager_keys(memory_backend: MemoryBackend) -> None: """clear() wipes all keys under this manager's own namespace only.""" manager = CacheManager(backend=memory_backend, key_prefix="cache:") @@ -502,7 +468,6 @@ async def test_clear_removes_all_manager_keys(memory_backend: MemoryBackend) -> assert await memory_backend.get("oauth_state:untouched") is not None -@pytest.mark.asyncio async def test_clear_pattern_delegates_to_backend_within_namespace( memory_backend: MemoryBackend, ) -> None: @@ -523,7 +488,6 @@ async def test_clear_pattern_delegates_to_backend_within_namespace( # --- Serialization errors ---------------------------------------------------- -@pytest.mark.asyncio async def test_set_non_json_serializable_raises_type_error( cache_manager: CacheManager, ) -> None: @@ -532,7 +496,6 @@ async def test_set_non_json_serializable_raises_type_error( await cache_manager.set("key", {1, 2, 3}) -@pytest.mark.asyncio async def test_get_with_corrupted_backend_content_returns_default( memory_backend: MemoryBackend, ) -> None: @@ -545,7 +508,6 @@ async def test_get_with_corrupted_backend_content_returns_default( assert await manager.get("bad", default="fallback") == "fallback" -@pytest.mark.asyncio async def test_get_with_non_utf8_content_returns_default( memory_backend: MemoryBackend, ) -> None: @@ -561,7 +523,6 @@ async def test_get_with_non_utf8_content_returns_default( # --- Construction / backend resolution ----------------------------------------- -@pytest.mark.asyncio async def test_manager_accepts_explicit_backend() -> None: """CacheManager(backend=...) uses the provided backend without touching BackendProxy.""" backend = MemoryBackend() @@ -580,7 +541,6 @@ async def test_manager_accepts_explicit_backend() -> None: await backend.clear() -@pytest.mark.asyncio async def test_manager_falls_back_to_backend_proxy( memory_backend: MemoryBackend, ) -> None: @@ -606,7 +566,6 @@ def test_manager_raises_when_no_backend_configured() -> None: BackendProxy.set(MemoryBackend()) -@pytest.mark.asyncio async def test_multiple_managers_independent_key_prefixes_same_backend( memory_backend: MemoryBackend, ) -> None: diff --git a/tests/test_cache_private.py b/tests/test_cache_private.py index 383941d..85ca05e 100644 --- a/tests/test_cache_private.py +++ b/tests/test_cache_private.py @@ -8,7 +8,6 @@ from collections.abc import AsyncIterator -import pytest from fastapi import FastAPI from fastapi import Request from fastapi import Response @@ -20,7 +19,6 @@ from fastapi_cachex.proxy import BackendProxy -@pytest.mark.asyncio async def test_private_responses_are_not_shared_between_users(): """Two callers on the same route must each see their own body.""" app = FastAPI() @@ -115,7 +113,6 @@ async def private_calls(): assert calls["n"] == 2 -@pytest.mark.asyncio async def test_private_error_response_passes_through(): """An uncacheable status is served as-is and stored nowhere.""" app = FastAPI() @@ -145,7 +142,6 @@ async def private_no_store(): assert client.get("/private-no-store").headers["Cache-Control"] == "no-store" -@pytest.mark.asyncio async def test_private_streaming_response_is_not_stored_and_carries_no_etag(): """A streamed private body cannot be fingerprinted, and must still not be stored. diff --git a/tests/test_cache_request_param.py b/tests/test_cache_request_param.py index 6114cb0..57c33ab 100644 --- a/tests/test_cache_request_param.py +++ b/tests/test_cache_request_param.py @@ -75,7 +75,6 @@ async def handler(**kwargs: Any) -> dict[str, str]: assert params[-1].kind is inspect.Parameter.VAR_KEYWORD -@pytest.mark.asyncio async def test_kwargs_handler_caches_and_does_not_leak_the_injected_param(): """The handler runs once and never sees `__cachex_request`.""" seen: list[list[str]] = [] @@ -96,7 +95,6 @@ async def handler(**kwargs: Any) -> dict[str, bool]: assert seen == [[]] -@pytest.mark.asyncio async def test_kwargs_handler_keeps_its_own_arguments(): """A real parameter alongside `**kwargs` still reaches the handler.""" seen: list[tuple[str, list[str]]] = [] diff --git a/tests/test_cache_status_headers.py b/tests/test_cache_status_headers.py index 4b14423..ef5fd91 100644 --- a/tests/test_cache_status_headers.py +++ b/tests/test_cache_status_headers.py @@ -8,7 +8,6 @@ import json -import pytest from fastapi import FastAPI from fastapi import Response from fastapi.testclient import TestClient @@ -20,6 +19,7 @@ from tests.live_servers import MEMCACHED_SERVER from tests.live_servers import REDIS_HOST from tests.live_servers import REDIS_PORT +from tests.live_servers import flush_memcached from tests.live_servers import requires_memcached from tests.live_servers import requires_redis @@ -50,7 +50,6 @@ async def missing(): assert calls["n"] == 3 -@pytest.mark.asyncio async def test_returned_error_leaves_no_backend_entry(): """The failing key must be absent from the backend entirely.""" app = FastAPI() @@ -65,7 +64,6 @@ async def boom(): assert await BackendProxy.get().get(_key("/boom")) is None -@pytest.mark.asyncio async def test_error_does_not_overwrite_a_good_cached_entry(): """A transient failure must not evict the last good response. @@ -223,7 +221,6 @@ def test_decode_entry_defaults_pre_v2_documents(): @requires_redis -@pytest.mark.asyncio async def test_redis_reads_pre_v2_documents(): """A pre-upgrade Redis entry is served as a plain 200.""" from fastapi_cachex.backends import AsyncRedisCacheBackend @@ -244,13 +241,12 @@ async def test_redis_reads_pre_v2_documents(): @requires_memcached -@pytest.mark.asyncio async def test_memcached_reads_pre_v2_documents(): """A pre-upgrade Memcached entry is served as a plain 200.""" from fastapi_cachex.backends import MemcachedBackend backend = MemcachedBackend(servers=[MEMCACHED_SERVER]) - await backend.clear() + await flush_memcached(backend) legacy = json.dumps( {"fingerprint": 'W/"old"', "content": "legacy", "media_type": "text/plain"} ) @@ -262,11 +258,10 @@ async def test_memcached_reads_pre_v2_documents(): assert entry.status_code == 200 assert entry.headers is None assert entry.content == b"legacy" - await backend.clear() + await flush_memcached(backend) @requires_redis -@pytest.mark.asyncio async def test_redis_round_trips_status_and_headers(): """Status and headers survive JSON serialization.""" from fastapi_cachex.backends import AsyncRedisCacheBackend @@ -287,13 +282,12 @@ async def test_redis_round_trips_status_and_headers(): @requires_memcached -@pytest.mark.asyncio async def test_memcached_round_trips_status_and_headers(): """Status and headers survive JSON serialization.""" from fastapi_cachex.backends import MemcachedBackend backend = MemcachedBackend(servers=[MEMCACHED_SERVER]) - await backend.clear() + await flush_memcached(backend) entry = CacheEntry( fingerprint="f", content=b"body", @@ -305,4 +299,4 @@ async def test_memcached_round_trips_status_and_headers(): await backend.set("v2-key", entry) assert await backend.get("v2-key") == entry - await backend.clear() + await flush_memcached(backend) diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 67e4c3a..416e027 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -1,4 +1,3 @@ -import pytest from fastapi import FastAPI from fastapi.testclient import TestClient @@ -21,7 +20,6 @@ async def backend_endpoint(backend: CacheBackend): # Actual test functions -@pytest.mark.asyncio async def test_get_cache_backend_falls_back_to_memory_without_a_backend(): """`CacheBackend` must work before any `@cache` route has run. @@ -39,7 +37,6 @@ async def test_get_cache_backend_falls_back_to_memory_without_a_backend(): assert get_app_cache().backend is BackendProxy.get() -@pytest.mark.asyncio async def test_get_cache_backend_with_memory_backend(): """Test that get_cache_backend returns the configured backend.""" backend = MemoryBackend() @@ -50,7 +47,6 @@ async def test_get_cache_backend_with_memory_backend(): assert response.json() == {"backend_type": "MemoryBackend"} -@pytest.mark.asyncio async def test_get_app_cache_falls_back_to_memory_without_a_backend(): """`AppCache` must work on its own, like `@cache` already does. @@ -68,7 +64,6 @@ async def test_get_app_cache_falls_back_to_memory_without_a_backend(): assert CacheManagerProxy.get() is manager -@pytest.mark.asyncio async def test_get_app_cache_uses_the_configured_backend(): """A configured backend must not be replaced by the fallback.""" backend = MemoryBackend() diff --git a/tests/test_lock.py b/tests/test_lock.py index dba7299..c54cdb2 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -13,7 +13,6 @@ from fastapi_cachex.types import CacheEntry -@pytest.mark.asyncio async def test_lock_basic_context_manager() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -27,7 +26,6 @@ async def test_lock_basic_context_manager() -> None: assert await backend.get("lock:test_job") is None -@pytest.mark.asyncio async def test_lock_acquire_non_blocking() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -44,7 +42,6 @@ async def test_lock_acquire_non_blocking() -> None: assert await lock1.locked() is False -@pytest.mark.asyncio async def test_lock_acquire_blocking_indefinite_retries_until_available() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -64,7 +61,6 @@ async def release_later() -> None: assert await lock2.release() is True -@pytest.mark.asyncio async def test_lock_acquire_blocking_timeout_returns_false() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -77,7 +73,6 @@ async def test_lock_acquire_blocking_timeout_returns_false() -> None: assert await lock1.release() is True -@pytest.mark.asyncio async def test_lock_context_manager_timeout_raises_lock_timeout_error() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -92,7 +87,6 @@ async def test_lock_context_manager_timeout_raises_lock_timeout_error() -> None: assert await lock1.release() is True -@pytest.mark.asyncio async def test_lock_extend() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -108,7 +102,6 @@ async def test_lock_extend() -> None: assert await lock1.extend(60) is False -@pytest.mark.asyncio async def test_lock_locked_status() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -123,7 +116,6 @@ async def test_lock_locked_status() -> None: assert await lock.locked() is False -@pytest.mark.asyncio async def test_lock_custom_key_prefix() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -137,7 +129,6 @@ async def test_lock_custom_key_prefix() -> None: assert await backend.get("custom_lock:custom") is None -@pytest.mark.asyncio async def test_lock_custom_backend() -> None: default_backend = MemoryBackend() custom_backend = MemoryBackend() @@ -151,7 +142,6 @@ async def test_lock_custom_backend() -> None: assert await custom_backend.get("lock:job") is None -@pytest.mark.asyncio async def test_lock_release_on_expired_lock_returns_false() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -165,7 +155,6 @@ async def test_lock_release_on_expired_lock_returns_false() -> None: assert await lock.release() is False -@pytest.mark.asyncio async def test_lock_token_uniqueness() -> None: lock1 = CacheLock("job") lock2 = CacheLock("job") @@ -174,7 +163,6 @@ async def test_lock_token_uniqueness() -> None: assert lock1._entry != lock2._entry -@pytest.mark.asyncio async def test_lock_raises_backend_not_found_if_no_backend_set() -> None: BackendProxy.set(None) lock = CacheLock("job") @@ -183,7 +171,6 @@ async def test_lock_raises_backend_not_found_if_no_backend_set() -> None: await lock.acquire(blocking=False) -@pytest.mark.asyncio async def test_lock_reentry_raises_runtime_error() -> None: backend = MemoryBackend() BackendProxy.set(backend) @@ -208,7 +195,6 @@ async def set_if_absent( return await super().set_if_absent(key, value, ttl=ttl) -@pytest.mark.asyncio async def test_lock_shared_instance_raises_runtime_error() -> None: backend = YieldingMemoryBackend() BackendProxy.set(backend) @@ -224,7 +210,6 @@ async def task_worker() -> None: assert any(res is None for res in results) -@pytest.mark.asyncio async def test_lock_acquire_cancelled_resets_is_held() -> None: backend = MemoryBackend() BackendProxy.set(backend) diff --git a/tests/test_proxybackend.py b/tests/test_proxybackend.py index 35cdd70..40d6c86 100644 --- a/tests/test_proxybackend.py +++ b/tests/test_proxybackend.py @@ -83,7 +83,6 @@ async def test_endpoint(): assert response2.status_code == 304 -@pytest.mark.asyncio async def test_backend_cleanup(clock: Clock): # Run cleanup task in async environment memory_backend = MemoryBackend() From 6ecbb1fef2d6b4b716416d3aa78e157bb630358b Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sun, 27 Sep 2026 11:15:43 +0000 Subject: [PATCH 2/3] test: close every live Redis and Memcached client inside its test With filterwarnings = ["error"], a client a test dropped without closing failed whichever later test the garbage collector happened to run in: ResourceWarnings for unclosed asyncio transports (Redis) and sockets (Memcached pools), surfaced as PytestUnraisableExceptionWarning. An autouse fixture records each AsyncRedisCacheBackend and MemcachedBackend as it is built (via __new__, so constructor warnings keep pointing at the test) and closes its client at teardown on the test's loop. The one sync test that drives Redis through TestClient closes it on the portal loop that owns the connections. --- CLAUDE.md | 2 +- docs/DEVELOPMENT.md | 7 ++++++ tests/backends/test_redis.py | 2 ++ tests/conftest.py | 43 ++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 634241e..b2718ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,7 @@ Five non-abstract atomic primitives live on the base class with non-atomic fallb `tests/conftest.py` sets `MemoryBackend` as the default backend via an `autouse=True` fixture for every test. Tests requiring Redis or Memcached must configure their own backends. The `memory_backend` fixture manages the cleanup task lifecycle. -`[tool.pytest.ini_options]` sets `asyncio_mode = "auto"` (no `@pytest.mark.asyncio`), `--strict-markers`, `xfail_strict = true` and `filterwarnings = ["error"]`: an expected warning needs `pytest.warns`, and live Memcached resets go through `flush_memcached` in `tests/live_servers.py`. The one global ignore covers starlette 1.0.0's deprecated anyio alias in the `lowest` tox env. +`[tool.pytest.ini_options]` sets `asyncio_mode = "auto"` (no `@pytest.mark.asyncio`), `--strict-markers`, `xfail_strict = true` and `filterwarnings = ["error"]`: an expected warning needs `pytest.warns`, live Memcached resets go through `flush_memcached` in `tests/live_servers.py`, and the autouse `close_network_clients` fixture closes every Redis/Memcached client a test builds (an unclosed socket's `ResourceWarning` would fail a later test). The one global ignore covers starlette 1.0.0's deprecated anyio alias in the `lowest` tox env. ### Code Quality Rules diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 7973564..8d419af 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -100,6 +100,13 @@ it makes the run louder, not safer. global ignore only for a third-party warning the tests cannot avoid, with a comment saying why. + An unclosed socket counts too: its `ResourceWarning` fails whichever test + is running when the garbage collector finds it. The autouse + `close_network_clients` fixture in `tests/conftest.py` closes every Redis + and Memcached client a test builds, on the test's own event loop. A sync + test that uses a Redis backend from inside `TestClient` closes it through + `client.portal`, because the connections belong to that loop. + ### Checking that a test can fail Coverage says a line ran, not that anything checked what it did. A test that diff --git a/tests/backends/test_redis.py b/tests/backends/test_redis.py index a1cfb0e..db69501 100644 --- a/tests/backends/test_redis.py +++ b/tests/backends/test_redis.py @@ -1204,6 +1204,8 @@ async def ttl_zero() -> dict[str, str]: ) # Clean up inside the client's event loop, which owns the pool. client.portal.call(backend.clear) # type: ignore[union-attr] + # types-redis predates aclose() (redis-py 5.0.1). + client.portal.call(backend.client.aclose) # type: ignore[union-attr,call-arg,attr-defined] assert first.status_code == 200 assert first.headers["Cache-Control"] == "max-age=0" diff --git a/tests/conftest.py b/tests/conftest.py index f31bcc1..a5463ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,21 @@ import asyncio import time +from collections.abc import AsyncGenerator from datetime import datetime from datetime import tzinfo from types import SimpleNamespace +from typing import Any import pytest import pytest_asyncio +from pymemcache import HashClient +from redis.asyncio import Redis +from fastapi_cachex.backends import MemcachedBackend from fastapi_cachex.backends import memory from fastapi_cachex.backends.base import BaseCacheBackend from fastapi_cachex.backends.memory import MemoryBackend +from fastapi_cachex.backends.redis import AsyncRedisCacheBackend from fastapi_cachex.manager_proxy import CacheManagerProxy from fastapi_cachex.proxy import BackendProxy from fastapi_cachex.session import manager as session_manager @@ -42,6 +48,43 @@ def setup_default_backend(): backend.stop_cleanup() +@pytest.fixture(autouse=True) +async def close_network_clients( + monkeypatch: pytest.MonkeyPatch, +) -> AsyncGenerator[None, None]: + """Close every Redis and Memcached client a test opens, when it ends. + + The backends have no close method of their own, so a client a test drops + keeps its sockets until the garbage collector finds it, often during a + later test. The `ResourceWarning` then fails whichever test that is. + Recording each backend as it is built and closing it here keeps every + socket inside the test that opened it. + + `__new__` records rather than `__init__`, so the constructor's warnings + still point at the test that called it. + + Both client libraries are imported at the top of this module: a test that + hides them from `import` is still patched while this teardown runs. + """ + opened: list[AsyncRedisCacheBackend | MemcachedBackend] = [] + + def record(cls: type[Any], *args: Any, **kwargs: Any) -> Any: + backend = object.__new__(cls) + opened.append(backend) + return backend + + for cls in (AsyncRedisCacheBackend, MemcachedBackend): + monkeypatch.setattr(cls, "__new__", record) + yield + for backend in opened: + # Missing if the constructor raised; a test may swap in a mock. + client = getattr(backend, "client", None) + if isinstance(client, Redis): + await client.aclose() # type: ignore[attr-defined] # not in types-redis + elif isinstance(client, HashClient): + client.close() + + @pytest.fixture(autouse=True) def reset_proxy_singletons(): """Clear the remaining proxy singletons around every test. From baa881dd168f6e802802d4fece958cf3a2cfd0a2 Mon Sep 17 00:00:00 2001 From: allen0099 Date: Sun, 27 Sep 2026 11:20:55 +0000 Subject: [PATCH 3/3] test: recognise live clients without importing the optional libraries The cleanup fixture imported pymemcache and redis at the top of tests/conftest.py, so the whole suite failed to collect without those extras. Match the client's type by module name instead. --- tests/conftest.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a5463ef..e987bfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,8 +8,6 @@ import pytest import pytest_asyncio -from pymemcache import HashClient -from redis.asyncio import Redis from fastapi_cachex.backends import MemcachedBackend from fastapi_cachex.backends import memory @@ -63,8 +61,9 @@ async def close_network_clients( `__new__` records rather than `__init__`, so the constructor's warnings still point at the test that called it. - Both client libraries are imported at the top of this module: a test that - hides them from `import` is still patched while this teardown runs. + Clients are recognised by the module of their type rather than with + `isinstance`, so this file imports neither optional client library, and + a mock a test swapped in is left alone. """ opened: list[AsyncRedisCacheBackend | MemcachedBackend] = [] @@ -78,10 +77,11 @@ def record(cls: type[Any], *args: Any, **kwargs: Any) -> Any: yield for backend in opened: # Missing if the constructor raised; a test may swap in a mock. - client = getattr(backend, "client", None) - if isinstance(client, Redis): - await client.aclose() # type: ignore[attr-defined] # not in types-redis - elif isinstance(client, HashClient): + client: Any = getattr(backend, "client", None) + module = type(client).__module__ + if module.startswith("redis."): + await client.aclose() + elif module.startswith("pymemcache."): client.close()