From 68d378d058b745b98362def37970183b5dba5386 Mon Sep 17 00:00:00 2001 From: Vishal Bala Date: Fri, 4 Sep 2026 11:15:38 +0200 Subject: [PATCH] fix(tests): isolate LangCache integration tests from the shared cache The LangCache integration suite flaked semi-randomly in the Service Tests job, a different test each run, on PRs touching nothing LangCache-related. Two tests flushed the entire managed cache while the suite runs under `pytest -n auto` against a single cache_id from repo secrets, so concurrent xdist workers and concurrent CI runs on different PRs wiped each other's entries. - Remove both whole-cache flushes, and add an autouse fixture that fails any test calling delete/adelete/clear/aclear. The flush wrappers keep their mocked unit coverage; the flush HTTP path is deliberately left untested, because no cache we share can safely be flushed. - Tag every write with a per-test scope token, so no test can observe or delete another's entries, and give every entry a TTL so the shared caches drain without anyone flushing them. - Filter the TTL-expiry tests on a scope-unique attribute, and widen their TTL so the pre-expiry assertion is not racing a two-second budget across two live round trips. num_results is a client-side slice only, since the service returns one result by default, so it cannot provide the isolation an attribute filter does. - Tighten the delete-by-attribute counts from >= 1 to the number actually stored, now that scope-unique attributes make the count knowable. - Join scope tokens onto punctuation-heavy attribute values with "_" rather than "-", which survives percent-encoding and is a text separator. - Autospec the unit-test SDK mock, so a renamed method or a changed signature fails there instead of being silently auto-vivified. - Fix two unit tests that were indented into another test and so were never collected. --- CONTRIBUTING.md | 7 + ...st_langcache_semantic_cache_integration.py | 292 +++++++++++------- tests/unit/test_langcache_semantic_cache.py | 147 ++++----- 3 files changed, 263 insertions(+), 183 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a38233306..ae29958c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -177,6 +177,13 @@ uv run pytest --cov=redisvl --cov-report=html **Note:** Tests requiring external APIs need appropriate API keys set as environment variables. +Where such an API is a *shared, stateful* service -- one managed instance reached by +every xdist worker and every concurrent CI run -- tests must namespace everything +they write and must never issue a whole-service flush, which would delete data +belonging to other workers and other pull requests' CI runs. See the module +docstring in `tests/integration/test_langcache_semantic_cache_integration.py` for a +worked example. + ## Documentation Documentation is served from the `docs/` directory and built using Sphinx. diff --git a/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py index 7a0ac52bb..b6459af0e 100644 --- a/tests/integration/test_langcache_semantic_cache_integration.py +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -4,6 +4,33 @@ - One with attributes configured - One without attributes configured +Both caches are shared, and by more writers than is obvious: pytest-xdist +spreads this suite across workers within a run (``make test-all`` uses +``-n auto``), and every CI run reaching the same ``cache_id`` from repo secrets +-- pull requests via ``.github/workflows/test.yml``, forks via +``test-fork-pr.yml``, pushes to main, and the nightly cron -- runs it again +concurrently. So the rules here are: + +- No test may flush a whole cache. ``delete()``/``clear()``/``adelete()``/ + ``aclear()`` wipe every entry, including ones another worker or another PR's + job stored moments earlier. This is a constraint, not a preference: there is + no safe way to test a global flush against a cache we do not exclusively own, + so the flush wrappers are covered against a mocked SDK in + tests/unit/test_langcache_semantic_cache.py, and the flush HTTP path itself + is deliberately left untested. +- Every test that writes tags its prompts, responses, and attribute values with + a unique ``scope`` token, so no test can observe or delete another's entries. +- Assert that your own scoped entry is (or is no longer) among the hits -- never + on ``hits[0]`` and never on ``hits`` being empty. LangCache returns one result + by default and prompts here differ only by the scope token, so a concurrent + run's semantically identical prompt is a legitimate candidate for that slot. + Where a test's subject is not retrieval itself, filter on a scoped attribute + so the service can only return your own entries. +- Write every entry with a TTL, so the shared caches self-clean without anyone + flushing them. Note that a TTL set on the constructor is silently ignored by + ``store()``, so it has to be passed per call -- do not hoist it into the + fixtures until that is fixed. + Env vars (loaded from .env locally, injected via CI): - LANGCACHE_WITH_ATTRIBUTES_API_KEY - LANGCACHE_WITH_ATTRIBUTES_CACHE_ID @@ -13,7 +40,10 @@ - LANGCACHE_NO_ATTRIBUTES_URL """ +import asyncio import os +import time +import uuid import pytest from dotenv import load_dotenv @@ -34,6 +64,11 @@ "LANGCACHE_NO_ATTRIBUTES_URL", ) +# TTL for entries whose own lifetime is not under test, so the shared caches +# drain on their own. Comfortably outlasts the slowest test that reads back what +# it wrote, while keeping concurrent runs' leftovers short-lived. +TEST_ENTRY_TTL = 60 + def _require_env_vars(var_names: tuple[str, ...]) -> dict[str, str]: missing = [name for name in var_names if not os.getenv(name)] @@ -46,6 +81,28 @@ def _require_env_vars(var_names: tuple[str, ...]) -> dict[str, str]: return {name: os.environ[name] for name in var_names} +@pytest.fixture +def scope() -> str: + """Token unique to each test invocation; see the module docstring for why.""" + + return uuid.uuid4().hex[:12] + + +@pytest.fixture(autouse=True) +def _ban_whole_cache_flush(monkeypatch: pytest.MonkeyPatch) -> None: + """Make the no-flush rule mechanical rather than a convention.""" + + for method in ("delete", "adelete", "clear", "aclear"): + monkeypatch.setattr( + LangCacheSemanticCache, + method, + lambda *args, **kwargs: pytest.fail( + "Whole-cache flush is banned in this suite -- it wipes other " + "workers' and other CI runs' entries. See the module docstring." + ), + ) + + @pytest.fixture def langcache_with_attrs() -> LangCacheSemanticCache: """LangCacheSemanticCache instance bound to a cache with attributes configured.""" @@ -77,162 +134,159 @@ def langcache_no_attrs() -> LangCacheSemanticCache: @pytest.mark.requires_api_keys class TestLangCacheSemanticCacheIntegrationWithAttributes: def test_store_and_check_sync( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: - prompt = "What is Redis?" - response = "Redis is an in-memory data store." + prompt = f"What is Redis? [{scope}]" + response = f"Redis is an in-memory data store. [{scope}]" - entry_id = langcache_with_attrs.store(prompt=prompt, response=response) + entry_id = langcache_with_attrs.store( + prompt=prompt, response=response, ttl=TEST_ENTRY_TTL + ) assert entry_id - hits = langcache_with_attrs.check(prompt=prompt, num_results=1) - assert hits - assert hits[0]["response"] == response - assert hits[0]["prompt"] == prompt + # Deliberately unfiltered: exact retrieval of a just-stored prompt is + # what this test is for, and the scoped prompt is what makes the exact + # strategy (tried before semantic) able to single it out. + hits = langcache_with_attrs.check(prompt=prompt) + assert any( + hit["prompt"] == prompt and hit["response"] == response for hit in hits + ), f"scoped entry not returned; got {hits}" def test_store_with_per_entry_ttl_expires( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: """Per-entry TTL should cause individual entries to expire.""" - prompt = "Per-entry TTL test" - response = "This entry should expire quickly." + prompt = f"Per-entry TTL test [{scope}]" + response = f"This entry should expire quickly. [{scope}]" + # Filtering on a scoped attribute makes the result set provably this + # test's own, so neither assertion depends on how the service ranks a + # concurrent run's near-identical prompt. + metadata = {"user_id": f"tenant_ttl_{scope}"} + # The TTL has to outlast a store round trip plus a search round trip + # against a shared managed service, or the entry can expire before the + # pre-expiry assertion runs. entry_id = langcache_with_attrs.store( prompt=prompt, response=response, - ttl=2, + metadata=metadata, + ttl=5, ) assert entry_id # Immediately after storing, the entry should be retrievable. - hits = langcache_with_attrs.check(prompt=prompt, num_results=5) - assert any(hit["response"] == response for hit in hits) + hits = langcache_with_attrs.check(prompt=prompt, attributes=metadata) + assert any( + hit["response"] == response for hit in hits + ), f"entry not retrievable before its TTL elapsed; got {hits}" # Wait for TTL to elapse and confirm the entry is no longer returned. - import time - - time.sleep(3) + time.sleep(6) - hits_after_ttl = langcache_with_attrs.check(prompt=prompt, num_results=5) + hits_after_ttl = langcache_with_attrs.check( + prompt=prompt, attributes=metadata, num_results=5 + ) assert not any(hit["response"] == response for hit in hits_after_ttl) @pytest.mark.asyncio async def test_store_and_check_async( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: - prompt = "What is Redis async?" - response = "Redis is an in-memory data store (async)." + prompt = f"What is Redis async? [{scope}]" + response = f"Redis is an in-memory data store (async). [{scope}]" - entry_id = await langcache_with_attrs.astore(prompt=prompt, response=response) + entry_id = await langcache_with_attrs.astore( + prompt=prompt, response=response, ttl=TEST_ENTRY_TTL + ) assert entry_id - hits = await langcache_with_attrs.acheck(prompt=prompt, num_results=1) - assert hits - assert hits[0]["response"] == response - assert hits[0]["prompt"] == prompt + hits = await langcache_with_attrs.acheck(prompt=prompt) + assert any( + hit["prompt"] == prompt and hit["response"] == response for hit in hits + ), f"scoped entry not returned; got {hits}" @pytest.mark.asyncio async def test_astore_with_per_entry_ttl_expires( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: """Async per-entry TTL should cause individual entries to expire.""" - prompt = "Async per-entry TTL test" - response = "This async entry should expire quickly." + prompt = f"Async per-entry TTL test [{scope}]" + response = f"This async entry should expire quickly. [{scope}]" + metadata = {"user_id": f"tenant_ttl_async_{scope}"} entry_id = await langcache_with_attrs.astore( prompt=prompt, response=response, - ttl=2, + metadata=metadata, + ttl=5, ) assert entry_id - hits = await langcache_with_attrs.acheck(prompt=prompt, num_results=5) - assert any(hit["response"] == response for hit in hits) - - import asyncio + hits = await langcache_with_attrs.acheck(prompt=prompt, attributes=metadata) + assert any( + hit["response"] == response for hit in hits + ), f"entry not retrievable before its TTL elapsed; got {hits}" - await asyncio.sleep(3) + await asyncio.sleep(6) hits_after_ttl = await langcache_with_attrs.acheck( prompt=prompt, + attributes=metadata, num_results=5, ) assert not any(hit["response"] == response for hit in hits_after_ttl) def test_store_with_metadata_and_check_with_attributes( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: - prompt = "Explain Redis search." - response = "Redis provides full-text search via Redis Search." + prompt = f"Explain Redis search. [{scope}]" + response = f"Redis provides full-text search via Redis Search. [{scope}]" # Use attribute names that are actually configured on this cache. - metadata = {"user_id": "tenant_a"} + metadata = {"user_id": f"tenant_a_{scope}"} entry_id = langcache_with_attrs.store( prompt=prompt, response=response, metadata=metadata, + ttl=TEST_ENTRY_TTL, ) assert entry_id hits = langcache_with_attrs.check( prompt=prompt, - attributes={"user_id": "tenant_a"}, + attributes=metadata, num_results=3, ) - assert hits - assert any(hit["response"] == response for hit in hits) - - def test_delete_and_clear_alias( - self, langcache_with_attrs: LangCacheSemanticCache - ) -> None: - """delete() and clear() should flush the whole cache.""" - - prompt = "Delete me" - response = "You won't see me again." - - langcache_with_attrs.store(prompt=prompt, response=response) - hits_before = langcache_with_attrs.check(prompt=prompt, num_results=5) - assert hits_before - - # delete() and clear() both flush the whole cache - langcache_with_attrs.delete() - hits_after_delete = langcache_with_attrs.check(prompt=prompt, num_results=5) - - # It is possible for other tests or data to exist; we only assert that - # the original response is no longer present if any hits are returned. - assert not any(hit["response"] == response for hit in hits_after_delete) - - langcache_with_attrs.store(prompt=prompt, response=response) - langcache_with_attrs.clear() - hits_after_clear = langcache_with_attrs.check(prompt=prompt, num_results=5) - assert not any(hit["response"] == response for hit in hits_after_clear) + assert any( + hit["response"] == response for hit in hits + ), f"attribute-filtered read did not return the scoped entry; got {hits}" def test_delete_by_id_and_by_attributes( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: - prompt = "Delete by id" - response = "Entry to delete by id." - metadata = {"user_id": "tenant_delete"} + prompt = f"Delete by id [{scope}]" + response = f"Entry to delete by id. [{scope}]" + metadata = {"user_id": f"tenant_delete_{scope}"} entry_id = langcache_with_attrs.store( prompt=prompt, response=response, metadata=metadata, + ttl=TEST_ENTRY_TTL, ) assert entry_id hits = langcache_with_attrs.check( - prompt=prompt, attributes=metadata, num_results=1 + prompt=prompt, attributes=metadata, num_results=5 ) - assert hits - assert hits[0]["entry_id"] == entry_id + assert any(hit["entry_id"] == entry_id for hit in hits) # delete by id langcache_with_attrs.delete_by_id(entry_id) hits_after_id_delete = langcache_with_attrs.check( - prompt=prompt, attributes=metadata, num_results=3 + prompt=prompt, attributes=metadata, num_results=5 ) assert not any(hit["entry_id"] == entry_id for hit in hits_after_id_delete) @@ -242,24 +296,28 @@ def test_delete_by_id_and_by_attributes( prompt=f"{prompt} {i}", response=f"{response} {i}", metadata=metadata, + ttl=TEST_ENTRY_TTL, ) delete_result = langcache_with_attrs.delete_by_attributes(attributes=metadata) assert isinstance(delete_result, dict) - assert delete_result.get("deleted_entries_count", 0) >= 1 + # The attribute value is scope-unique, so all three stored entries must + # be deleted -- not merely one of them. + assert delete_result.get("deleted_entries_count", 0) >= 3 @pytest.mark.asyncio async def test_async_delete_variants( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: - prompt = "Async delete by attributes" - response = "Async delete candidate" - metadata = {"user_id": "tenant_async"} + prompt = f"Async delete by attributes [{scope}]" + response = f"Async delete candidate [{scope}]" + metadata = {"user_id": f"tenant_async_{scope}"} entry_id = await langcache_with_attrs.astore( prompt=prompt, response=response, metadata=metadata, + ttl=TEST_ENTRY_TTL, ) assert entry_id @@ -277,34 +335,29 @@ async def test_async_delete_variants( prompt=f"{prompt} {i}", response=f"{response} {i}", metadata=metadata, + ttl=TEST_ENTRY_TTL, ) delete_result = await langcache_with_attrs.adelete_by_attributes( attributes=metadata ) assert isinstance(delete_result, dict) - assert delete_result.get("deleted_entries_count", 0) >= 1 - - # Finally, aclear() should flush the cache. - await langcache_with_attrs.aclear() - hits_after_clear = await langcache_with_attrs.acheck( - prompt=prompt, num_results=5 - ) - assert not any(hit["response"] == response for hit in hits_after_clear) + assert delete_result.get("deleted_entries_count", 0) >= 2 def test_attribute_value_with_comma_and_slash_is_encoded_for_llm_string( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: """llm_string attribute values with commas/slashes are client-encoded.""" - prompt = "Attribute encoding for llm_string" - response = "Response for encoded llm_string." + prompt = f"Attribute encoding for llm_string [{scope}]" + response = f"Response for encoded llm_string. [{scope}]" - raw_llm_string = "tenant,with/slash" + raw_llm_string = f"tenant,with/slash_{scope}" entry_id = langcache_with_attrs.store( prompt=prompt, response=response, metadata={"llm_string": raw_llm_string}, + ttl=TEST_ENTRY_TTL, ) assert entry_id @@ -315,16 +368,17 @@ def test_attribute_value_with_comma_and_slash_is_encoded_for_llm_string( attributes={"llm_string": raw_llm_string}, num_results=3, ) - assert hits - # Response must match, and metadata should contain the original value - # (the client handles encoding/decoding around the LangCache API). - assert any(hit["response"] == response for hit in hits) + # One hit must match on both counts: the response, and the metadata + # round-tripped back to its original value (the client handles + # encoding/decoding around the LangCache API). assert any( - hit.get("metadata", {}).get("llm_string") == raw_llm_string for hit in hits - ) + hit["response"] == response + and hit.get("metadata", {}).get("llm_string") == raw_llm_string + for hit in hits + ), f"encoded llm_string did not round-trip; got {hits}" def test_attribute_value_with_all_tokenizer_separators_round_trip_and_filter( - self, langcache_with_attrs: LangCacheSemanticCache + self, langcache_with_attrs: LangCacheSemanticCache, scope: str ) -> None: """All tokenizer separator characters should round-trip via filters. @@ -335,15 +389,16 @@ def test_attribute_value_with_all_tokenizer_separators_round_trip_and_filter( """ separators = ",.<>{}[]\"':;!@#$%^&*()-+=~" - raw_llm_string = f"tenant {separators} value" + raw_llm_string = f"tenant {separators} value {scope}" - prompt = "Attribute encoding for all tokenizer separators" - response = "Response for all tokenizer separators." + prompt = f"Attribute encoding for all tokenizer separators [{scope}]" + response = f"Response for all tokenizer separators. [{scope}]" entry_id = langcache_with_attrs.store( prompt=prompt, response=response, metadata={"llm_string": raw_llm_string}, + ttl=TEST_ENTRY_TTL, ) assert entry_id @@ -372,6 +427,7 @@ def test_attribute_values_with_special_chars_round_trip_and_filter( self, langcache_with_attrs: LangCacheSemanticCache, raw_value: str, + scope: str, ) -> None: """Backslash and question-mark values should round-trip via filters. @@ -380,6 +436,10 @@ def test_attribute_values_with_special_chars_round_trip_and_filter( filterable and round-trip correctly. """ + # Joined with an underscore: unlike "-", it is not a text separator and + # survives percent-encoding untouched, so scoping adds no character + # beyond the ones this test exists to pin down. + raw_value = f"{raw_value}_{scope}" prompt = f"Special chars attribute {raw_value}" response = f"Response for {raw_value}" @@ -387,6 +447,7 @@ def test_attribute_values_with_special_chars_round_trip_and_filter( prompt=prompt, response=response, metadata={"llm_string": raw_value}, + ttl=TEST_ENTRY_TTL, ) assert entry_id @@ -413,16 +474,19 @@ def test_attribute_values_with_special_chars_round_trip_and_filter( @pytest.mark.requires_api_keys class TestLangCacheSemanticCacheIntegrationWithoutAttributes: def test_error_on_store_with_metadata_when_no_attributes_configured( - self, langcache_no_attrs: LangCacheSemanticCache + self, langcache_no_attrs: LangCacheSemanticCache, scope: str ) -> None: - prompt = "Attributes not configured" + prompt = f"Attributes not configured [{scope}]" response = "This should fail due to missing attributes configuration." + # Scoped and TTL'd even though the store is expected to raise: if this + # cache is ever given attributes, the write would start succeeding. with pytest.raises(RuntimeError) as exc: langcache_no_attrs.store( prompt=prompt, response=response, - metadata={"tenant": "tenant_without_attrs"}, + metadata={"tenant": f"tenant_without_attrs_{scope}"}, + ttl=TEST_ENTRY_TTL, ) assert "attributes are not configured for this cache" in str(exc.value).lower() @@ -441,14 +505,20 @@ def test_error_on_check_with_attributes_when_no_attributes_configured( assert "attributes are not configured for this cache" in str(exc.value).lower() def test_basic_store_and_check_works_without_attributes( - self, langcache_no_attrs: LangCacheSemanticCache + self, langcache_no_attrs: LangCacheSemanticCache, scope: str ) -> None: - prompt = "Plain cache without attributes" - response = "This should be cached successfully." + prompt = f"Plain cache without attributes [{scope}]" + response = f"This should be cached successfully. [{scope}]" - entry_id = langcache_no_attrs.store(prompt=prompt, response=response) + entry_id = langcache_no_attrs.store( + prompt=prompt, response=response, ttl=TEST_ENTRY_TTL + ) assert entry_id + # This cache has no attributes configured, so a scoped filter is not + # available here -- the unique prompt and the exact search strategy are + # the only isolation this test can get. hits = langcache_no_attrs.check(prompt=prompt) - assert hits - assert any(hit["response"] == response for hit in hits) + assert any( + hit["response"] == response for hit in hits + ), f"scoped entry not returned; got {hits}" diff --git a/tests/unit/test_langcache_semantic_cache.py b/tests/unit/test_langcache_semantic_cache.py index aa874429c..e49ca06ee 100644 --- a/tests/unit/test_langcache_semantic_cache.py +++ b/tests/unit/test_langcache_semantic_cache.py @@ -2,7 +2,7 @@ import builtins import importlib.util -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import MagicMock, create_autospec, patch import pytest @@ -11,17 +11,25 @@ @pytest.fixture def mock_langcache_client(): - """Create a mock LangCache client via the wrapper factory method.""" + """Create a mock LangCache client via the wrapper factory method. + + Autospecced against the real SDK class, so a renamed method or a changed + signature fails here rather than being silently auto-vivified. That matters + more than usual: whole-cache flush has no live-service coverage anywhere + (see the module docstring in + tests/integration/test_langcache_semantic_cache_integration.py), so these + mocks are the only thing standing between an SDK change and a broken + delete()/clear(). + """ + + # Local import: at module scope this would break collection in an + # environment without the optional langcache dependency. + from langcache import LangCache + with patch.object(LangCacheSemanticCache, "_create_client") as mock_create_client: - mock_client = MagicMock() + mock_client = create_autospec(LangCache, instance=True) mock_create_client.return_value = mock_client - # Mock context manager - mock_client.__enter__ = MagicMock(return_value=mock_client) - mock_client.__exit__ = MagicMock(return_value=None) - mock_client.__aenter__ = AsyncMock(return_value=mock_client) - mock_client.__aexit__ = AsyncMock(return_value=None) - yield mock_create_client, mock_client @@ -124,7 +132,7 @@ async def test_astore(self, mock_langcache_client): # Mock the async set method - returns a Pydantic model with entry_id mock_response = MagicMock() mock_response.entry_id = "entry-456" - mock_client.set_async = AsyncMock(return_value=mock_response) + mock_client.set_async.return_value = mock_response cache = LangCacheSemanticCache( name="test", @@ -175,10 +183,9 @@ async def test_astore_with_per_entry_ttl(self, mock_langcache_client): """astore() should pass per-entry TTL as ttl_millis to LangCache client.""" _, mock_client = mock_langcache_client - # Ensure set_async is an AsyncMock so it can be awaited. mock_response = MagicMock() mock_response.entry_id = "entry-999" - mock_client.set_async = AsyncMock(return_value=mock_response) + mock_client.set_async.return_value = mock_response cache = LangCacheSemanticCache( name="test", @@ -262,7 +269,7 @@ async def test_acheck(self, mock_langcache_client): mock_response = MagicMock() mock_response.data = [mock_entry] - mock_client.search_async = AsyncMock(return_value=mock_response) + mock_client.search_async.return_value = mock_response cache = LangCacheSemanticCache( name="test", @@ -376,67 +383,67 @@ def test_check_with_attributes(self, mock_langcache_client): "topic": r"programming,with/encoding\and?", } - def test_store_with_empty_metadata_does_not_send_attributes( - self, mock_langcache_client - ): - """Empty metadata {} should not be forwarded as attributes to the SDK.""" - _, mock_client = mock_langcache_client + def test_store_with_empty_metadata_does_not_send_attributes( + self, mock_langcache_client + ): + """Empty metadata {} should not be forwarded as attributes to the SDK.""" + _, mock_client = mock_langcache_client - mock_response = MagicMock() - mock_response.entry_id = "entry-empty" - mock_client.set.return_value = mock_response + mock_response = MagicMock() + mock_response.entry_id = "entry-empty" + mock_client.set.return_value = mock_response - cache = LangCacheSemanticCache( - name="test", - server_url="https://api.example.com", - cache_id="test-cache", - api_key="test-key", - ) + cache = LangCacheSemanticCache( + name="test", + server_url="https://api.example.com", + cache_id="test-cache", + api_key="test-key", + ) - entry_id = cache.store( - prompt="Q?", - response="A", - metadata={}, # should be ignored - ) + entry_id = cache.store( + prompt="Q?", + response="A", + metadata={}, # should be ignored + ) - assert entry_id == "entry-empty" - # Ensure attributes kwarg was NOT sent when metadata is {} - _, call_kwargs = mock_client.set.call_args - assert "attributes" not in call_kwargs + assert entry_id == "entry-empty" + # Ensure attributes kwarg was NOT sent when metadata is {} + _, call_kwargs = mock_client.set.call_args + assert "attributes" not in call_kwargs - def test_check_with_empty_attributes_does_not_send_attributes( - self, mock_langcache_client - ): - """Empty attributes {} should not be forwarded to the SDK search call.""" - _, mock_client = mock_langcache_client - - mock_entry = MagicMock() - mock_entry.model_dump.return_value = { - "id": "e1", - "prompt": "Q?", - "response": "A", - "similarity": 1.0, - "created_at": 0.0, - "updated_at": 0.0, - "attributes": {}, - } - mock_response = MagicMock() - mock_response.data = [mock_entry] - mock_client.search.return_value = mock_response - - cache = LangCacheSemanticCache( - name="test", - server_url="https://api.example.com", - cache_id="test-cache", - api_key="test-key", - ) + def test_check_with_empty_attributes_does_not_send_attributes( + self, mock_langcache_client + ): + """Empty attributes {} should not be forwarded to the SDK search call.""" + _, mock_client = mock_langcache_client - results = cache.check(prompt="Q?", attributes={}) # should be ignored - assert results and results[0]["entry_id"] == "e1" + mock_entry = MagicMock() + mock_entry.model_dump.return_value = { + "id": "e1", + "prompt": "Q?", + "response": "A", + "similarity": 1.0, + "created_at": 0.0, + "updated_at": 0.0, + "attributes": {}, + } + mock_response = MagicMock() + mock_response.data = [mock_entry] + mock_client.search.return_value = mock_response + + cache = LangCacheSemanticCache( + name="test", + server_url="https://api.example.com", + cache_id="test-cache", + api_key="test-key", + ) - # Ensure attributes kwarg was NOT sent when attributes is {} - _, call_kwargs = mock_client.search.call_args - assert "attributes" not in call_kwargs + results = cache.check(prompt="Q?", attributes={}) # should be ignored + assert results and results[0]["entry_id"] == "e1" + + # Ensure attributes kwarg was NOT sent when attributes is {} + _, call_kwargs = mock_client.search.call_args + assert "attributes" not in call_kwargs def test_delete(self, mock_langcache_client): """Test deleting the entire cache using flush().""" @@ -458,8 +465,6 @@ async def test_adelete(self, mock_langcache_client): """Test async deleting the entire cache using flush().""" _, mock_client = mock_langcache_client - mock_client.flush_async = AsyncMock() - cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", @@ -491,8 +496,6 @@ async def test_aclear(self, mock_langcache_client): """Test that async clear() calls adelete() which uses flush().""" _, mock_client = mock_langcache_client - mock_client.flush_async = AsyncMock() - cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", @@ -565,7 +568,7 @@ async def test_adelete_by_attributes_with_valid_attributes( mock_response = MagicMock() mock_response.model_dump.return_value = {"deleted_entries_count": 3} - mock_client.delete_query_async = AsyncMock(return_value=mock_response) + mock_client.delete_query_async.return_value = mock_response cache = LangCacheSemanticCache( name="test",