Files
ss-tools/backend/tests/services/test_git_summary_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

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