- ~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
237 lines
8.9 KiB
Python
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
|