330 lines
9.8 KiB
Python
330 lines
9.8 KiB
Python
"""Integration tests for prompt-cache."""
|
|
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from semantic_llm_cache import cache, clear_cache, get_stats, invalidate
|
|
from semantic_llm_cache.backends import MemoryBackend
|
|
|
|
|
|
class TestEndToEnd:
|
|
"""End-to-end integration tests."""
|
|
|
|
def test_full_cache_workflow(self):
|
|
"""Test complete cache workflow from hit to miss."""
|
|
backend = MemoryBackend()
|
|
call_count = {"count": 0}
|
|
|
|
@cache(backend=backend)
|
|
def llm_function(prompt: str) -> str:
|
|
call_count["count"] += 1
|
|
return f"Response to: {prompt}"
|
|
|
|
# First call - miss
|
|
result1 = llm_function("What is Python?")
|
|
assert result1 == "Response to: What is Python?"
|
|
assert call_count["count"] == 1
|
|
|
|
# Second call - hit
|
|
result2 = llm_function("What is Python?")
|
|
assert result2 == "Response to: What is Python?"
|
|
assert call_count["count"] == 1
|
|
|
|
# Different prompt - miss
|
|
result3 = llm_function("What is Rust?")
|
|
assert result3 == "Response to: What is Rust?"
|
|
assert call_count["count"] == 2
|
|
|
|
def test_stats_integration(self):
|
|
"""Test statistics tracking."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend, namespace="test")
|
|
def llm_function(prompt: str) -> str:
|
|
return f"Response to: {prompt}"
|
|
|
|
# Generate some activity
|
|
llm_function("prompt 1")
|
|
llm_function("prompt 1") # Hit
|
|
llm_function("prompt 2")
|
|
|
|
stats = get_stats(namespace="test")
|
|
assert stats["total_requests"] >= 2
|
|
|
|
def test_clear_cache_integration(self):
|
|
"""Test clearing cache affects function behavior."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend)
|
|
def llm_function(prompt: str) -> str:
|
|
return f"Response to: {prompt}"
|
|
|
|
llm_function("test prompt")
|
|
|
|
# Clear cache
|
|
cleared = clear_cache()
|
|
assert cleared >= 0
|
|
|
|
# Function should still work
|
|
result = llm_function("test prompt")
|
|
assert result == "Response to: test prompt"
|
|
|
|
def test_invalidate_integration(self):
|
|
"""Test invalidating cache entries."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend)
|
|
def llm_function(prompt: str) -> str:
|
|
return f"Response to: {prompt}"
|
|
|
|
llm_function("Python programming")
|
|
llm_function("Rust programming")
|
|
|
|
# Invalidate Python entries
|
|
count = invalidate("Python")
|
|
assert count >= 0
|
|
|
|
def test_multiple_namespaces(self):
|
|
"""Test cache isolation across namespaces."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend, namespace="app1")
|
|
def app1_llm(prompt: str) -> str:
|
|
return f"App1: {prompt}"
|
|
|
|
@cache(backend=backend, namespace="app2")
|
|
def app2_llm(prompt: str) -> str:
|
|
return f"App2: {prompt}"
|
|
|
|
result1 = app1_llm("test")
|
|
result2 = app2_llm("test")
|
|
|
|
assert result1 == "App1: test"
|
|
assert result2 == "App2: test"
|
|
|
|
def test_ttl_expiration_integration(self):
|
|
"""Test TTL expiration in real workflow."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend, ttl=1) # 1 second TTL
|
|
def llm_function(prompt: str) -> str:
|
|
return f"Response to: {prompt}"
|
|
|
|
llm_function("test prompt")
|
|
|
|
# Immediate second call - hit
|
|
llm_function("test prompt")
|
|
|
|
# Wait for expiration
|
|
time.sleep(1.5)
|
|
|
|
# Should miss (cached entry expired)
|
|
llm_function("test prompt")
|
|
|
|
|
|
class TestComplexScenarios:
|
|
"""Tests for complex real-world scenarios."""
|
|
|
|
def test_high_volume_caching(self):
|
|
"""Test cache behavior with many entries."""
|
|
backend = MemoryBackend(max_size=100)
|
|
call_count = {"count": 0}
|
|
|
|
@cache(backend=backend)
|
|
def llm_function(prompt: str) -> str:
|
|
call_count["count"] += 1
|
|
return f"Response {call_count['count']}"
|
|
|
|
# Add many entries
|
|
for i in range(150):
|
|
llm_function(f"prompt {i}")
|
|
|
|
# Some entries should have been evicted
|
|
stats = backend.get_stats()
|
|
assert stats["size"] <= 100
|
|
|
|
def test_concurrent_like_access(self):
|
|
"""Test multiple calls to same cached entry."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend)
|
|
def llm_function(prompt: str) -> str:
|
|
return f"Unique: {time.time()}"
|
|
|
|
# Multiple calls
|
|
results = [llm_function("test") for _ in range(5)]
|
|
|
|
# All should return same result (cached)
|
|
assert len(set(results)) == 1
|
|
|
|
def test_different_return_types(self):
|
|
"""Test caching different return types."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend)
|
|
def return_dict(prompt: str) -> dict:
|
|
return {"key": "value"}
|
|
|
|
@cache(backend=backend)
|
|
def return_list(prompt: str) -> list:
|
|
return [1, 2, 3]
|
|
|
|
@cache(backend=backend)
|
|
def return_string(prompt: str) -> str:
|
|
return "string response"
|
|
|
|
# Use unique prompts to avoid cache collision
|
|
assert isinstance(return_dict("test_dict"), dict)
|
|
assert isinstance(return_list("test_list"), list)
|
|
assert isinstance(return_string("test_string"), str)
|
|
|
|
def test_empty_and_none_responses(self):
|
|
"""Test caching empty and None responses."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend)
|
|
def return_empty(prompt: str) -> str:
|
|
return ""
|
|
|
|
@cache(backend=backend)
|
|
def return_none(prompt: str) -> None:
|
|
return None
|
|
|
|
assert return_empty("empty_test") == ""
|
|
assert return_none("none_test") is None
|
|
|
|
# Should still cache (second calls should hit cache)
|
|
assert return_empty("empty_test") == ""
|
|
assert return_none("none_test") is None
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""Tests for error handling in various scenarios."""
|
|
|
|
def test_function_with_exception(self):
|
|
"""Test function that raises exception."""
|
|
from semantic_llm_cache.exceptions import PromptCacheError
|
|
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend)
|
|
def failing_function(prompt: str) -> str:
|
|
if "error" in prompt:
|
|
raise ValueError("Test error")
|
|
return "OK"
|
|
|
|
# Normal call works
|
|
assert failing_function("normal") == "OK"
|
|
|
|
# Error call raises PromptCacheError (wrapped exception)
|
|
with pytest.raises(PromptCacheError):
|
|
failing_function("error prompt")
|
|
|
|
# Normal call still works
|
|
assert failing_function("normal") == "OK"
|
|
|
|
def test_backend_error_handling(self):
|
|
"""Test that backend wraps errors properly."""
|
|
from semantic_llm_cache.backends.memory import MemoryBackend
|
|
|
|
# Use MemoryBackend which has proper error handling
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend)
|
|
def working_func(prompt: str) -> str:
|
|
return f"Response to: {prompt}"
|
|
|
|
# Normal operation works
|
|
assert working_func("test") == "Response to: test"
|
|
|
|
# Second call hits cache
|
|
assert working_func("test") == "Response to: test"
|
|
|
|
# Backend properly stores and retrieves entries
|
|
stats = backend.get_stats()
|
|
assert stats["hits"] >= 1
|
|
|
|
|
|
class TestPromptNormalization:
|
|
"""Tests for prompt normalization effects."""
|
|
|
|
def test_whitespace_normalization(self):
|
|
"""Test prompts with different whitespace are cached separately."""
|
|
backend = MemoryBackend()
|
|
call_count = {"count": 0}
|
|
|
|
@cache(backend=backend)
|
|
def llm_function(prompt: str) -> str:
|
|
call_count["count"] += 1
|
|
return f"Response: {prompt}"
|
|
|
|
llm_function("What is Python?")
|
|
llm_function("What is Python?") # Extra spaces
|
|
|
|
# Normalization should make these the same
|
|
# Note: This depends on the normalization implementation
|
|
assert call_count["count"] >= 1
|
|
|
|
def test_case_sensitivity(self):
|
|
"""Test case sensitivity in caching."""
|
|
backend = MemoryBackend()
|
|
call_count = {"count": 0}
|
|
|
|
@cache(backend=backend)
|
|
def llm_function(prompt: str) -> str:
|
|
call_count["count"] += 1
|
|
return f"Response: {prompt}"
|
|
|
|
llm_function("What is Python?")
|
|
llm_function("what is python?")
|
|
|
|
# Case differences create different cache entries
|
|
# (normalization doesn't lowercase by default)
|
|
assert call_count["count"] >= 1
|
|
|
|
|
|
class TestConfigurationCombinations:
|
|
"""Tests for various configuration combinations."""
|
|
|
|
def test_no_caching_config(self):
|
|
"""Test configuration with caching disabled."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend, enabled=False)
|
|
def llm_function(prompt: str) -> str:
|
|
return f"Response: {time.time()}"
|
|
|
|
result1 = llm_function("test")
|
|
time.sleep(0.01)
|
|
result2 = llm_function("test")
|
|
|
|
# Without caching, results differ
|
|
assert result1 != result2
|
|
|
|
def test_zero_ttl(self):
|
|
"""Test zero TTL means immediate expiration."""
|
|
backend = MemoryBackend()
|
|
|
|
@cache(backend=backend, ttl=0)
|
|
def llm_function(prompt: str) -> str:
|
|
return f"Response: {prompt}"
|
|
|
|
llm_function("test")
|
|
# Entry immediately expires, so next call is a miss
|
|
llm_function("test")
|
|
|
|
def test_infinite_ttl(self):
|
|
"""Test None TTL means never expire."""
|
|
backend = MemoryBackend()
|
|
call_count = {"count": 0}
|
|
|
|
@cache(backend=backend, ttl=None)
|
|
def llm_function(prompt: str) -> str:
|
|
call_count["count"] += 1
|
|
return f"Response: {prompt}"
|
|
|
|
llm_function("test")
|
|
llm_function("test")
|
|
|
|
assert call_count["count"] == 1
|