Files
ss-tools/backend/tests/services/test_environment_stats_cache.py
busya 488a8f349b test(backend): raise coverage to 95%+ statements and branches (97.8%/95.0%)
- ~60 new/extended test files across api, core, plugins, services, schemas:
  routes, superset clients, task_manager, lineage, git, translate,
  dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl
- .coveragerc: enable branch coverage; exclude src/__tests__ (test files)
  and src/scripts (CLI/ops tools) from the denominator
- bug fixes found while testing:
  * settings: PUT /settings/reports registered under duplicated prefix
  * schemas/lineage: FleetReportDTO missing run_status (route always 500)
  * dashboard_testing/baseline_inheritance: visual entry read wrong field
  * superset_client/_databases: logger extra name shadowed LogRecord attr
  * routes/datasets: _yaml_string_paths recursion without yield from
  * translate/sql_generator: restore explicit-type timestamp contract
  * baseline_catalog: remove unreachable dashboard_id fallback
- conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test
  filename collision, TMPDIR-safe integration fixtures
2026-08-19 17:14:32 +03:00

237 lines
8.9 KiB
Python

# #region Test.EnvironmentStatsCache [C:3] [TYPE Module] [SEMANTICS test,environment,stats,cache,ttl]
# @BRIEF Tests for EnvironmentStatsCache — TTL, coalescing, invalidation, key building.
# @RELATION BINDS_TO -> [Services.EnvironmentStatsCache]
# @TEST_EDGE: empty_key -> ValueError
# @TEST_EDGE: non_callable_factory -> TypeError
# @TEST_EDGE: expired_entry -> refetches
# @TEST_EDGE: invalidate -> entry dropped, refetches
# @TEST_EDGE: waiter_cancellation -> shared provider task survives
# @TEST_EDGE: lru_eviction -> oldest untouched entry evicted first
import asyncio
from unittest.mock import patch
import pytest
import src.services.environment_stats_cache as mod
from src.services.environment_stats_cache import (
EnvironmentStatsCache,
build_env_stats_cache_key,
)
class TestEnvironmentStatsCache:
"""Verify EnvironmentStatsCache TTL/coalescing/invalidation behavior."""
# #region Std.TestEnvironmentStatsCache.Init [C:2] [TYPE Function]
# @BRIEF Limits clamped to at least 1; defaults applied.
def test_init_clamps_limits(self):
cache = EnvironmentStatsCache(ttl_seconds=0, max_entries=-3)
assert cache.ttl_seconds == 1
assert cache.max_entries == 1
default = EnvironmentStatsCache()
assert default.ttl_seconds == mod.DEFAULT_TTL_SECONDS
assert default.max_entries == mod.DEFAULT_MAX_ENTRIES
# #endregion Std.TestEnvironmentStatsCache.Init
# #region Std.TestEnvironmentStatsCache.MissThenHit [C:2] [TYPE Function]
# @BRIEF First call invokes factory; second served from cache; inflight cleaned.
@pytest.mark.asyncio
async def test_miss_then_hit(self):
cache = EnvironmentStatsCache()
calls = []
async def factory():
calls.append(1)
return {"count": 7}
value, hit = await cache.get_or_create("env-1", factory)
assert value == {"count": 7}
assert hit is False
value, hit = await cache.get_or_create("env-1", factory)
assert value == {"count": 7}
assert hit is True
assert len(calls) == 1
assert "env-1" not in cache._inflight
# #endregion Std.TestEnvironmentStatsCache.MissThenHit
# #region Std.TestEnvironmentStatsCache.NoneValueCached [C:2] [TYPE Function]
# @BRIEF Unlike the LLM summary cache, a None result is a valid cached value here.
@pytest.mark.asyncio
async def test_none_result_is_cached(self):
cache = EnvironmentStatsCache()
calls = []
async def factory():
calls.append(1)
return None
value, hit = await cache.get_or_create("k", factory)
assert value is None
assert hit is False
value, hit = await cache.get_or_create("k", factory)
assert value is None
assert hit is True
assert len(calls) == 1
# #endregion Std.TestEnvironmentStatsCache.NoneValueCached
# #region Std.TestEnvironmentStatsCache.ExpiredRefetch [C:2] [TYPE Function]
# @BRIEF Expired entry dropped; factory invoked again.
@pytest.mark.asyncio
async def test_expired_entry_refetches(self):
cache = EnvironmentStatsCache(ttl_seconds=60)
calls = []
async def factory():
calls.append(1)
return 1
clock = {"t": 0.0}
with patch.object(mod.time, "monotonic", side_effect=lambda: clock["t"]):
value, hit = await cache.get_or_create("k", factory)
assert (value, hit) == (1, False)
clock["t"] = 61.0
value, hit = await cache.get_or_create("k", factory)
assert (value, hit) == (1, False)
assert len(calls) == 2
# #endregion Std.TestEnvironmentStatsCache.ExpiredRefetch
# #region Std.TestEnvironmentStatsCache.Invalidate [C:2] [TYPE Function]
# @BRIEF invalidate() drops the entry; next call refetches.
@pytest.mark.asyncio
async def test_invalidate_refetches(self):
cache = EnvironmentStatsCache()
calls = []
async def factory():
calls.append(1)
return "v1"
await cache.get_or_create("k", factory)
value, hit = await cache.get_or_create("k", factory)
assert hit is True
cache.invalidate("k")
value, hit = await cache.get_or_create("k", factory)
assert hit is False
assert len(calls) == 2
# invalidate on a missing key is a no-op
cache.invalidate("missing")
# #endregion Std.TestEnvironmentStatsCache.Invalidate
# #region Std.TestEnvironmentStatsCache.Validation [C:2] [TYPE Function]
# @BRIEF Invalid keys and factories rejected before work.
@pytest.mark.asyncio
async def test_validation(self):
cache = EnvironmentStatsCache()
for bad in ("", " ", 5, None):
with pytest.raises(ValueError):
await cache.get_or_create(bad, lambda: 1)
with pytest.raises(TypeError):
await cache.get_or_create("k", "nope")
# #endregion Std.TestEnvironmentStatsCache.Validation
# #region Std.TestEnvironmentStatsCache.FactoryError [C:2] [TYPE Function]
# @BRIEF Factory exception propagates and is never cached.
@pytest.mark.asyncio
async def test_factory_exception_not_cached(self):
cache = EnvironmentStatsCache()
calls = []
async def failing():
calls.append(1)
raise TimeoutError("superset down")
with pytest.raises(TimeoutError):
await cache.get_or_create("k", failing)
with pytest.raises(TimeoutError):
await cache.get_or_create("k", failing)
assert len(calls) == 2
assert "k" not in cache._entries
# #endregion Std.TestEnvironmentStatsCache.FactoryError
# #region Std.TestEnvironmentStatsCache.Coalescing [C:2] [TYPE Function]
# @BRIEF Concurrent callers share one factory invocation.
@pytest.mark.asyncio
async def test_concurrent_callers_coalesce(self):
cache = EnvironmentStatsCache()
started = asyncio.Event()
release = asyncio.Event()
calls = 0
async def factory():
nonlocal calls
calls += 1
started.set()
await release.wait()
return "stats"
t1 = asyncio.create_task(cache.get_or_create("k", factory))
await started.wait()
t2 = asyncio.create_task(cache.get_or_create("k", factory))
await asyncio.sleep(0.01)
release.set()
r1, r2 = await asyncio.gather(t1, t2)
assert r1 == ("stats", False)
assert r2 == ("stats", False)
assert calls == 1
# #endregion Std.TestEnvironmentStatsCache.Coalescing
# #region Std.TestEnvironmentStatsCache.Cancellation [C:2] [TYPE Function]
# @BRIEF Cancelled waiter must not cancel the shared provider task.
@pytest.mark.asyncio
async def test_waiter_cancellation_keeps_shared_task(self):
cache = EnvironmentStatsCache()
started = asyncio.Event()
release = asyncio.Event()
async def factory():
started.set()
await release.wait()
return "survived"
waiter = asyncio.create_task(cache.get_or_create("k", factory))
await started.wait()
waiter.cancel()
with pytest.raises(asyncio.CancelledError):
await waiter
release.set()
value, hit = await cache.get_or_create("k", factory)
assert value == "survived"
assert hit is False
# #endregion Std.TestEnvironmentStatsCache.Cancellation
# #region Std.TestEnvironmentStatsCache.LruEviction [C:2] [TYPE Function]
# @BRIEF Bounded cache evicts least-recently-used entry when full.
@pytest.mark.asyncio
async def test_lru_eviction(self):
cache = EnvironmentStatsCache(max_entries=2)
async def factory(v):
return v
await cache.get_or_create("a", lambda: factory("A"))
await cache.get_or_create("b", lambda: factory("B"))
value, hit = await cache.get_or_create("a", lambda: factory("A2"))
assert hit is True
await cache.get_or_create("c", lambda: factory("C"))
assert "b" not in cache._entries
assert "a" in cache._entries
assert "c" in cache._entries
# #endregion Std.TestEnvironmentStatsCache.LruEviction
class TestBuildEnvStatsCacheKey:
"""Verify build_env_stats_cache_key."""
# #region Std.TestBuildEnvStatsCacheKey.Key [C:2] [TYPE Function]
# @BRIEF Key includes env id and normalized user; anonymous fallback for empty user.
def test_key_with_user(self):
assert build_env_stats_cache_key("env-1", " Admin ") == "env:env-1:user:admin"
def test_key_anonymous_fallback(self):
assert build_env_stats_cache_key("env-1", None) == "env:env-1:user:anon"
assert build_env_stats_cache_key("env-1", "") == "env:env-1:user:anon"
assert build_env_stats_cache_key("env-1", " ") == "env:env-1:user:anon"
# #endregion Std.TestBuildEnvStatsCacheKey.Key
# #endregion Test.EnvironmentStatsCache