Phase 2 of Memory Management System - volatile memory tier:
- VolatileCacheService: Redis-backed TTL storage
- Volatile router with full CRUD operations
- Predefined namespaces: weather, news, financial, transit, traffic,
air_quality, sports, social, system, context, custom
- Each namespace has appropriate default TTL (1min to 1hr)
- Refresh schedule support via cron expressions
- Scheduler integration endpoint: GET /volatile/scheduled
Endpoints:
- GET/POST/DELETE /volatile/{namespace}/{key}
- GET/DELETE /volatile/{namespace}
- GET /volatile/stats
- GET /volatile/scheduled
- GET /volatile/namespaces
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
288 lines
9.4 KiB
Python
288 lines
9.4 KiB
Python
"""
|
|
Tests for volatile cache router and service.
|
|
|
|
Tests:
|
|
- Volatile record CRUD operations
|
|
- Namespace listing and management
|
|
- Scheduled record retrieval
|
|
- TTL behavior
|
|
"""
|
|
|
|
import pytest
|
|
from datetime import datetime
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from src.models.volatile import (
|
|
VolatileRecord,
|
|
VolatileRecordCreate,
|
|
VolatileRecordResponse,
|
|
VolatileListResponse,
|
|
VolatileScheduledResponse,
|
|
VolatileStatsResponse,
|
|
VolatileDeleteResponse,
|
|
VolatileBulkDeleteResponse,
|
|
VolatileNamespace,
|
|
NAMESPACE_DEFAULT_TTL,
|
|
)
|
|
|
|
|
|
class TestVolatileModels:
|
|
"""Test volatile data models."""
|
|
|
|
def test_volatile_record_creation(self):
|
|
"""Test VolatileRecord model creation."""
|
|
record = VolatileRecord(
|
|
key="rotterdam",
|
|
namespace="weather",
|
|
data={"temperature": 18, "conditions": "Cloudy"},
|
|
source="openweathermap",
|
|
ttl=1800,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert record.key == "rotterdam"
|
|
assert record.namespace == "weather"
|
|
assert record.data["temperature"] == 18
|
|
assert record.ttl == 1800
|
|
assert record.refresh_schedule is None
|
|
|
|
def test_volatile_record_with_schedule(self):
|
|
"""Test VolatileRecord with refresh schedule."""
|
|
record = VolatileRecord(
|
|
key="nos-headlines",
|
|
namespace="news",
|
|
data={"headlines": ["Test headline"]},
|
|
source="nos.nl",
|
|
ttl=3600,
|
|
refresh_schedule="0 * * * *",
|
|
user="jpmschweitzer",
|
|
)
|
|
assert record.refresh_schedule == "0 * * * *"
|
|
|
|
def test_volatile_record_create(self):
|
|
"""Test VolatileRecordCreate model."""
|
|
create = VolatileRecordCreate(
|
|
data={"price": 150.50, "change": 2.3},
|
|
source="alpha_vantage",
|
|
ttl=300,
|
|
)
|
|
assert create.data["price"] == 150.50
|
|
assert create.ttl == 300
|
|
|
|
def test_volatile_record_response(self):
|
|
"""Test VolatileRecordResponse model."""
|
|
response = VolatileRecordResponse(
|
|
key="rotterdam",
|
|
namespace="weather",
|
|
data={"temperature": 18},
|
|
source="openweathermap",
|
|
created_at=datetime.utcnow(),
|
|
updated_at=datetime.utcnow(),
|
|
ttl=1800,
|
|
ttl_remaining=1500,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert response.ttl_remaining == 1500
|
|
assert response.ttl == 1800
|
|
|
|
|
|
class TestVolatileNamespaces:
|
|
"""Test volatile namespaces and defaults."""
|
|
|
|
def test_all_namespaces_have_default_ttl(self):
|
|
"""Verify all namespaces have default TTLs defined."""
|
|
for ns in VolatileNamespace:
|
|
assert ns in NAMESPACE_DEFAULT_TTL, f"Missing TTL for {ns}"
|
|
assert NAMESPACE_DEFAULT_TTL[ns] > 0
|
|
|
|
def test_weather_default_ttl(self):
|
|
"""Test weather namespace default TTL."""
|
|
assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.WEATHER] == 1800 # 30 min
|
|
|
|
def test_financial_default_ttl(self):
|
|
"""Test financial namespace default TTL."""
|
|
assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.FINANCIAL] == 300 # 5 min
|
|
|
|
def test_sports_default_ttl(self):
|
|
"""Test sports namespace default TTL (fast updates)."""
|
|
assert NAMESPACE_DEFAULT_TTL[VolatileNamespace.SPORTS] == 60 # 1 min
|
|
|
|
|
|
class TestVolatileListResponse:
|
|
"""Test list response models."""
|
|
|
|
def test_list_response(self):
|
|
"""Test VolatileListResponse model."""
|
|
response = VolatileListResponse(
|
|
namespace="weather",
|
|
keys=["rotterdam", "amsterdam", "utrecht"],
|
|
count=3,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert response.count == 3
|
|
assert "rotterdam" in response.keys
|
|
|
|
|
|
class TestVolatileScheduledResponse:
|
|
"""Test scheduled records response."""
|
|
|
|
def test_scheduled_response_empty(self):
|
|
"""Test empty scheduled response."""
|
|
response = VolatileScheduledResponse(
|
|
records=[],
|
|
count=0,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert response.count == 0
|
|
assert response.records == []
|
|
|
|
def test_scheduled_response_with_records(self):
|
|
"""Test scheduled response with records."""
|
|
record = VolatileRecordResponse(
|
|
key="nos-headlines",
|
|
namespace="news",
|
|
data={"headlines": []},
|
|
source="nos.nl",
|
|
created_at=datetime.utcnow(),
|
|
updated_at=datetime.utcnow(),
|
|
ttl=3600,
|
|
ttl_remaining=3000,
|
|
refresh_schedule="0 */6 * * *",
|
|
user="jpmschweitzer",
|
|
)
|
|
response = VolatileScheduledResponse(
|
|
records=[record],
|
|
count=1,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert response.count == 1
|
|
assert response.records[0].refresh_schedule == "0 */6 * * *"
|
|
|
|
|
|
class TestVolatileStatsResponse:
|
|
"""Test stats response model."""
|
|
|
|
def test_stats_response(self):
|
|
"""Test VolatileStatsResponse model."""
|
|
response = VolatileStatsResponse(
|
|
total_records=15,
|
|
by_namespace={"weather": 3, "news": 5, "financial": 7},
|
|
scheduled_count=2,
|
|
total_memory_bytes=None,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert response.total_records == 15
|
|
assert response.by_namespace["weather"] == 3
|
|
assert response.scheduled_count == 2
|
|
|
|
|
|
class TestVolatileDeleteResponses:
|
|
"""Test delete response models."""
|
|
|
|
def test_delete_response(self):
|
|
"""Test VolatileDeleteResponse model."""
|
|
response = VolatileDeleteResponse(
|
|
key="rotterdam",
|
|
namespace="weather",
|
|
deleted=True,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert response.deleted is True
|
|
|
|
def test_delete_not_found(self):
|
|
"""Test delete response when record not found."""
|
|
response = VolatileDeleteResponse(
|
|
key="nonexistent",
|
|
namespace="weather",
|
|
deleted=False,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert response.deleted is False
|
|
|
|
def test_bulk_delete_response(self):
|
|
"""Test VolatileBulkDeleteResponse model."""
|
|
response = VolatileBulkDeleteResponse(
|
|
namespace="weather",
|
|
deleted_count=5,
|
|
user="jpmschweitzer",
|
|
)
|
|
assert response.deleted_count == 5
|
|
assert response.namespace == "weather"
|
|
|
|
|
|
class TestVolatileService:
|
|
"""Test VolatileCacheService functionality."""
|
|
|
|
@pytest.fixture
|
|
def mock_redis(self):
|
|
"""Create mock Redis client."""
|
|
redis = AsyncMock()
|
|
redis.get = AsyncMock(return_value=None)
|
|
redis.setex = AsyncMock()
|
|
redis.delete = AsyncMock(return_value=1)
|
|
redis.ttl = AsyncMock(return_value=1500)
|
|
redis.scan_iter = MagicMock(return_value=iter([]))
|
|
return redis
|
|
|
|
@pytest.fixture
|
|
def mock_settings(self):
|
|
"""Create mock settings."""
|
|
settings = MagicMock()
|
|
settings.volatile_default_ttl = 3600
|
|
return settings
|
|
|
|
@pytest.fixture
|
|
def volatile_service(self, mock_redis, mock_settings):
|
|
"""Create VolatileCacheService with mocks."""
|
|
from src.services.volatile_service import VolatileCacheService
|
|
return VolatileCacheService(
|
|
redis_client=mock_redis,
|
|
settings=mock_settings
|
|
)
|
|
|
|
def test_build_key(self, volatile_service):
|
|
"""Test Redis key building."""
|
|
key = volatile_service._build_key("jpmschweitzer", "weather", "rotterdam")
|
|
assert key.startswith("jpmschweitzer:volatile:weather:")
|
|
assert len(key) > 30 # Has hash suffix
|
|
|
|
def test_build_pattern(self, volatile_service):
|
|
"""Test pattern building."""
|
|
pattern = volatile_service._build_pattern("jpmschweitzer", "weather")
|
|
assert pattern == "jpmschweitzer:volatile:weather:*"
|
|
|
|
def test_build_pattern_all(self, volatile_service):
|
|
"""Test pattern building for all namespaces."""
|
|
pattern = volatile_service._build_pattern("jpmschweitzer")
|
|
assert pattern == "jpmschweitzer:volatile:*"
|
|
|
|
def test_get_default_ttl_known_namespace(self, volatile_service):
|
|
"""Test default TTL for known namespace."""
|
|
ttl = volatile_service._get_default_ttl("weather")
|
|
assert ttl == 1800 # Weather namespace default
|
|
|
|
def test_get_default_ttl_unknown_namespace(self, volatile_service):
|
|
"""Test default TTL for unknown namespace."""
|
|
ttl = volatile_service._get_default_ttl("unknown_namespace")
|
|
assert ttl == 3600 # Falls back to settings default
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_not_found(self, volatile_service, mock_redis):
|
|
"""Test get when record not found."""
|
|
mock_redis.get.return_value = None
|
|
result = await volatile_service.get("jpmschweitzer", "weather", "rotterdam")
|
|
assert result is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_success(self, volatile_service, mock_redis):
|
|
"""Test successful delete."""
|
|
mock_redis.delete.return_value = 1
|
|
result = await volatile_service.delete("jpmschweitzer", "weather", "rotterdam")
|
|
assert result is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_not_found(self, volatile_service, mock_redis):
|
|
"""Test delete when record not found."""
|
|
mock_redis.delete.return_value = 0
|
|
result = await volatile_service.delete("jpmschweitzer", "weather", "nonexistent")
|
|
assert result is False
|