diff --git a/CLAUDE.md b/CLAUDE.md index 6e49003..b2718ff 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`, 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 - 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..8d419af 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -82,6 +82,31 @@ 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. + + 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/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..db69501 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: @@ -1248,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" @@ -1257,7 +1215,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 +1235,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 +1269,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/conftest.py b/tests/conftest.py index f31bcc1..e987bfd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,19 @@ 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 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 +46,45 @@ 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. + + 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] = [] + + 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: Any = getattr(backend, "client", None) + module = type(client).__module__ + if module.startswith("redis."): + await client.aclose() + elif module.startswith("pymemcache."): + client.close() + + @pytest.fixture(autouse=True) def reset_proxy_singletons(): """Clear the remaining proxy singletons around every test. 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()