- ~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
275 lines
11 KiB
Python
275 lines
11 KiB
Python
# #region Test.GitSummaryCache [C:3] [TYPE Module] [SEMANTICS test,git,summary,cache,coalescing]
|
|
# @BRIEF Tests for GitSummaryCache — TTL cache, in-flight coalescing, LRU eviction, key building.
|
|
# @RELATION BINDS_TO -> [Services.GitSummaryCache]
|
|
# @TEST_EDGE: empty_key -> ValueError
|
|
# @TEST_EDGE: non_callable_factory -> TypeError
|
|
# @TEST_EDGE: empty_factory_result -> RuntimeError, never cached
|
|
# @TEST_EDGE: factory_exception -> propagates, never cached
|
|
# @TEST_EDGE: expired_entry -> refetches
|
|
# @TEST_EDGE: waiter_cancellation -> shared provider task survives
|
|
# @TEST_EDGE: lru_eviction -> oldest untouched entry evicted first
|
|
# @TEST_EDGE: non_string_key_inputs -> TypeError
|
|
|
|
import asyncio
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import src.services.git_summary_cache as mod
|
|
from src.services.git_summary_cache import GitSummaryCache, build_git_summary_cache_key
|
|
|
|
|
|
class TestGitSummaryCache:
|
|
"""Verify GitSummaryCache TTL/coalescing/eviction behavior."""
|
|
|
|
# #region Std.TestGitSummaryCache.Init [C:2] [TYPE Function]
|
|
# @BRIEF Limits are normalized to at least 1; defaults applied.
|
|
def test_init_clamps_limits(self):
|
|
cache = GitSummaryCache(ttl_seconds=0, max_entries=-5)
|
|
assert cache.ttl_seconds == 1
|
|
assert cache.max_entries == 1
|
|
|
|
default = GitSummaryCache()
|
|
assert default.ttl_seconds == mod.DEFAULT_TTL_SECONDS
|
|
assert default.max_entries == mod.DEFAULT_MAX_ENTRIES
|
|
# #endregion Std.TestGitSummaryCache.Init
|
|
|
|
# #region Std.TestGitSummaryCache.MissThenHit [C:2] [TYPE Function]
|
|
# @BRIEF First call invokes factory; second call served from cache; inflight cleaned up.
|
|
@pytest.mark.asyncio
|
|
async def test_miss_then_hit(self):
|
|
cache = GitSummaryCache()
|
|
calls = []
|
|
|
|
async def factory():
|
|
calls.append(1)
|
|
return "summary"
|
|
|
|
value, hit = await cache.get_or_create("k", factory)
|
|
assert value == "summary"
|
|
assert hit is False
|
|
assert "k" not in cache._inflight
|
|
|
|
value, hit = await cache.get_or_create("k", factory)
|
|
assert value == "summary"
|
|
assert hit is True
|
|
assert len(calls) == 1
|
|
assert "k" not in cache._inflight
|
|
# #endregion Std.TestGitSummaryCache.MissThenHit
|
|
|
|
# #region Std.TestGitSummaryCache.ExpiredRefetch [C:2] [TYPE Function]
|
|
# @BRIEF Expired entry is dropped and the factory is invoked again.
|
|
@pytest.mark.asyncio
|
|
async def test_expired_entry_refetches(self):
|
|
cache = GitSummaryCache(ttl_seconds=60)
|
|
calls = []
|
|
|
|
async def factory():
|
|
calls.append(1)
|
|
return "fresh"
|
|
|
|
# Controlled clock: asyncio's event loop also reads time.monotonic, so a
|
|
# plain side_effect list would exhaust — use a mutable clock instead.
|
|
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) == ("fresh", False)
|
|
clock["t"] = 61.0 # entry expires at 60 → stale
|
|
value, hit = await cache.get_or_create("k", factory)
|
|
assert (value, hit) == ("fresh", False)
|
|
assert len(calls) == 2
|
|
# #endregion Std.TestGitSummaryCache.ExpiredRefetch
|
|
|
|
# #region Std.TestGitSummaryCache.Validation [C:2] [TYPE Function]
|
|
# @BRIEF Invalid keys and factories are rejected before any work.
|
|
@pytest.mark.asyncio
|
|
async def test_empty_or_non_string_key_rejected(self):
|
|
cache = GitSummaryCache()
|
|
for bad in ("", " ", 123, None, b"k"):
|
|
with pytest.raises(ValueError):
|
|
await cache.get_or_create(bad, lambda: "x")
|
|
# #endregion Std.TestGitSummaryCache.Validation
|
|
|
|
# #region Std.TestGitSummaryCache.NonCallable [C:2] [TYPE Function]
|
|
# @BRIEF Non-callable factory raises TypeError.
|
|
@pytest.mark.asyncio
|
|
async def test_non_callable_factory_rejected(self):
|
|
cache = GitSummaryCache()
|
|
with pytest.raises(TypeError):
|
|
await cache.get_or_create("k", "not-callable")
|
|
# #endregion Std.TestGitSummaryCache.NonCallable
|
|
|
|
# #region Std.TestGitSummaryCache.EmptyResult [C:2] [TYPE Function]
|
|
# @BRIEF Empty factory result raises and is never cached.
|
|
@pytest.mark.asyncio
|
|
async def test_factory_empty_result_raises_and_not_cached(self):
|
|
cache = GitSummaryCache()
|
|
calls = []
|
|
|
|
async def empty_factory():
|
|
calls.append(1)
|
|
return " "
|
|
|
|
with pytest.raises(RuntimeError, match="empty change summary"):
|
|
await cache.get_or_create("k", empty_factory)
|
|
assert len(calls) == 1
|
|
# Retry invokes the factory again — failure is not cached.
|
|
with pytest.raises(RuntimeError, match="empty change summary"):
|
|
await cache.get_or_create("k", empty_factory)
|
|
assert len(calls) == 2
|
|
assert "k" not in cache._entries
|
|
# #endregion Std.TestGitSummaryCache.EmptyResult
|
|
|
|
# #region Std.TestGitSummaryCache.FactoryError [C:2] [TYPE Function]
|
|
# @BRIEF Factory exception propagates to the caller and is not cached.
|
|
@pytest.mark.asyncio
|
|
async def test_factory_exception_propagates_and_not_cached(self):
|
|
cache = GitSummaryCache()
|
|
calls = []
|
|
|
|
async def failing_factory():
|
|
calls.append(1)
|
|
raise ConnectionError("provider down")
|
|
|
|
with pytest.raises(ConnectionError):
|
|
await cache.get_or_create("k", failing_factory)
|
|
with pytest.raises(ConnectionError):
|
|
await cache.get_or_create("k", failing_factory)
|
|
assert len(calls) == 2
|
|
assert "k" not in cache._entries
|
|
assert "k" not in cache._inflight
|
|
# #endregion Std.TestGitSummaryCache.FactoryError
|
|
|
|
# #region Std.TestGitSummaryCache.Coalescing [C:2] [TYPE Function]
|
|
# @BRIEF Concurrent callers for the same key invoke the factory exactly once.
|
|
@pytest.mark.asyncio
|
|
async def test_concurrent_callers_coalesce(self):
|
|
cache = GitSummaryCache()
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
calls = 0
|
|
|
|
async def factory():
|
|
nonlocal calls
|
|
calls += 1
|
|
started.set()
|
|
await release.wait()
|
|
return "coalesced"
|
|
|
|
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 == ("coalesced", False)
|
|
assert r2 == ("coalesced", False)
|
|
assert calls == 1
|
|
assert "k" not in cache._inflight
|
|
# #endregion Std.TestGitSummaryCache.Coalescing
|
|
|
|
# #region Std.TestGitSummaryCache.Cancellation [C:2] [TYPE Function]
|
|
# @BRIEF A cancelled waiter must not cancel the shared provider task (asyncio.shield).
|
|
@pytest.mark.asyncio
|
|
async def test_waiter_cancellation_keeps_shared_task(self):
|
|
cache = GitSummaryCache()
|
|
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
|
|
|
|
# The shared provider task survived the waiter cancellation.
|
|
release.set()
|
|
value, hit = await cache.get_or_create("k", factory)
|
|
assert value == "survived"
|
|
assert hit is False
|
|
assert "k" not in cache._inflight
|
|
# #endregion Std.TestGitSummaryCache.Cancellation
|
|
|
|
# #region Std.TestGitSummaryCache.LruEviction [C:2] [TYPE Function]
|
|
# @BRIEF Bounded cache evicts the least-recently-used entry when full.
|
|
@pytest.mark.asyncio
|
|
async def test_lru_eviction(self):
|
|
cache = GitSummaryCache(max_entries=2)
|
|
|
|
async def factory(value):
|
|
return value
|
|
|
|
await cache.get_or_create("a", lambda: factory("A"))
|
|
await cache.get_or_create("b", lambda: factory("B"))
|
|
# Hit "a" → becomes most-recently-used.
|
|
value, hit = await cache.get_or_create("a", lambda: factory("A2"))
|
|
assert hit is True
|
|
# Insert "c" → evicts "b", keeps "a" and "c".
|
|
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.TestGitSummaryCache.LruEviction
|
|
|
|
# #region Std.TestGitSummaryCache.MaxEntriesClamp [C:2] [TYPE Function]
|
|
# @BRIEF Zero/negative max_entries is clamped to 1; single-slot cache evicts every insert.
|
|
@pytest.mark.asyncio
|
|
async def test_single_slot_cache_evicts(self):
|
|
cache = GitSummaryCache(max_entries=0)
|
|
|
|
async def factory(value):
|
|
return value
|
|
|
|
await cache.get_or_create("a", lambda: factory("A"))
|
|
await cache.get_or_create("b", lambda: factory("B"))
|
|
assert "a" not in cache._entries
|
|
value, hit = await cache.get_or_create("b", lambda: factory("B2"))
|
|
assert hit is True
|
|
# #endregion Std.TestGitSummaryCache.MaxEntriesClamp
|
|
|
|
|
|
class TestBuildGitSummaryCacheKey:
|
|
"""Verify build_git_summary_cache_key."""
|
|
|
|
# #region Std.TestBuildGitSummaryCacheKey.HappyPath [C:2] [TYPE Function]
|
|
# @BRIEF Key embeds content hash, normalized language, prompt hash, stripped model.
|
|
def test_happy_path(self):
|
|
key, content_hash = build_git_summary_cache_key("line1\nline2", " En ", "prompt", " model ")
|
|
assert key.startswith("git-summary:v1:")
|
|
assert content_hash == __import__("hashlib").sha256(b"line1\nline2").hexdigest()
|
|
assert ":en:" in key
|
|
assert "model" in key
|
|
assert " En " not in key and " model " not in key
|
|
# #endregion Std.TestBuildGitSummaryCacheKey.HappyPath
|
|
|
|
# #region Std.TestBuildGitSummaryCacheKey.LineEndings [C:2] [TYPE Function]
|
|
# @BRIEF CRLF/CR diffs normalize to the same hash and key as LF.
|
|
def test_line_ending_normalization(self):
|
|
key1, hash1 = build_git_summary_cache_key("a\r\nb\r\nc", "en", "tpl", "m")
|
|
key2, hash2 = build_git_summary_cache_key("a\nb\nc", "en", "tpl", "m")
|
|
key3, hash3 = build_git_summary_cache_key("a\rb\rc", "en", "tpl", "m")
|
|
assert hash1 == hash2 == hash3
|
|
assert key1 == key2 == key3
|
|
# #endregion Std.TestBuildGitSummaryCacheKey.LineEndings
|
|
|
|
# #region Std.TestBuildGitSummaryCacheKey.TypeError [C:2] [TYPE Function]
|
|
# @BRIEF Non-string inputs are rejected.
|
|
def test_non_string_inputs_rejected(self):
|
|
with pytest.raises(TypeError):
|
|
build_git_summary_cache_key(123, "en", "tpl", "m")
|
|
with pytest.raises(TypeError):
|
|
build_git_summary_cache_key("diff", None, "tpl", "m")
|
|
# #endregion Std.TestBuildGitSummaryCacheKey.TypeError
|
|
|
|
# #region Std.TestBuildGitSummaryCacheKey.Deterministic [C:2] [TYPE Function]
|
|
# @BRIEF Identical inputs produce an identical key and hash.
|
|
def test_deterministic(self):
|
|
assert build_git_summary_cache_key("d", "en", "t", "m") == build_git_summary_cache_key("d", "en", "t", "m")
|
|
# #endregion Std.TestBuildGitSummaryCacheKey.Deterministic
|
|
# #endregion Test.GitSummaryCache
|