|
13 | 13 | import os |
14 | 14 | import subprocess |
15 | 15 | import sys |
| 16 | +import time |
16 | 17 | from pathlib import Path |
17 | 18 |
|
18 | 19 | import pytest |
@@ -163,3 +164,67 @@ def test_different_secret_invalidates_cache(self, cache_dir, tmp_path): |
163 | 164 | # Force regeneration with a fresh secret. |
164 | 165 | supertool._validator_cache_secret() |
165 | 166 | assert supertool._validator_cache_read("k4") is None |
| 167 | + |
| 168 | + |
| 169 | +class TestValidatorCacheTtl: |
| 170 | + """TTL expiry on the validator cache read path (validator_cache_ttl_hours).""" |
| 171 | + |
| 172 | + @pytest.fixture |
| 173 | + def cache_dir(self, tmp_path, monkeypatch): |
| 174 | + original_home = Path.home |
| 175 | + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) |
| 176 | + yield tmp_path / ".cache" / "supertool" / "validators" |
| 177 | + monkeypatch.setattr(Path, "home", original_home) |
| 178 | + |
| 179 | + def _set_ttl(self, monkeypatch, hours): |
| 180 | + monkeypatch.setattr(supertool, "_load_config", |
| 181 | + lambda: {"validator_cache_ttl_hours": hours}) |
| 182 | + |
| 183 | + def test_fresh_entry_is_a_hit(self, cache_dir, monkeypatch): |
| 184 | + self._set_ttl(monkeypatch, 24) |
| 185 | + data = {"tool": "fake", "ok": True, "count": 0} |
| 186 | + supertool._validator_cache_write("ttl1", data) |
| 187 | + assert supertool._validator_cache_read("ttl1") == data |
| 188 | + |
| 189 | + def test_expired_entry_is_a_miss(self, cache_dir, monkeypatch): |
| 190 | + self._set_ttl(monkeypatch, 24) |
| 191 | + data = {"tool": "fake", "ok": True, "count": 0} |
| 192 | + supertool._validator_cache_write("ttl2", data) |
| 193 | + # Backdate the file mtime well past the 24h window. |
| 194 | + old = time.time() - 100 * 3600 |
| 195 | + os.utime(supertool._validator_cache_path("ttl2"), (old, old)) |
| 196 | + assert supertool._validator_cache_read("ttl2") is None |
| 197 | + |
| 198 | + def test_ttl_zero_disables_expiry(self, cache_dir, monkeypatch): |
| 199 | + self._set_ttl(monkeypatch, 0) |
| 200 | + data = {"tool": "fake", "ok": True, "count": 0} |
| 201 | + supertool._validator_cache_write("ttl3", data) |
| 202 | + old = time.time() - 100 * 3600 |
| 203 | + os.utime(supertool._validator_cache_path("ttl3"), (old, old)) |
| 204 | + assert supertool._validator_cache_read("ttl3") == data |
| 205 | + |
| 206 | + |
| 207 | +class TestValidatorResultIsCacheable: |
| 208 | + """Non-deterministic engine failures must not be cached (poisoning guard).""" |
| 209 | + |
| 210 | + def test_ok_result_is_cacheable(self): |
| 211 | + assert supertool._validator_result_is_cacheable({"ok": True, "errors": []}) |
| 212 | + |
| 213 | + def test_real_finding_is_cacheable(self): |
| 214 | + data = {"ok": False, "errors": [ |
| 215 | + {"code": "rector.refactor", "msg": "Would apply SomeRector"}]} |
| 216 | + assert supertool._validator_result_is_cacheable(data) |
| 217 | + |
| 218 | + def test_rector_system_error_not_cacheable(self): |
| 219 | + data = {"ok": False, "errors": [ |
| 220 | + {"code": "rector.error", |
| 221 | + "msg": 'System error: "ClassReflection must be resolved for class XTest"'}]} |
| 222 | + assert not supertool._validator_result_is_cacheable(data) |
| 223 | + |
| 224 | + def test_mcp_transport_error_not_cacheable(self): |
| 225 | + data = {"ok": False, "errors": [{"code": "mcp", "msg": "connection refused"}]} |
| 226 | + assert not supertool._validator_result_is_cacheable(data) |
| 227 | + |
| 228 | + def test_exit_code_failure_not_cacheable(self): |
| 229 | + data = {"ok": False, "errors": [{"code": "rector.exit", "msg": "rector exit 1"}]} |
| 230 | + assert not supertool._validator_result_is_cacheable(data) |
0 commit comments