Initial commit: SearXNG CLI Toolkit v1.6.0
Multi-instance failover, exponential-backoff retry, SQLite cache, batch mode, domain filter, cross-engine dedup, result sorting, CSV export, structured logging, enhanced Markdown conversion, 155 pytest tests, Gitea Actions CI
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""Tests for scripts/cache.py — SQLite-backed result cache.
|
||||
|
||||
Covers: cache key stability, key whitelist (format/auth excluded),
|
||||
get/put roundtrip, TTL expiry, caller-shorter-TTL override, clear, stats,
|
||||
and the ttl<=0 disable behavior.
|
||||
"""
|
||||
import sqlite3
|
||||
import time
|
||||
|
||||
import cache
|
||||
|
||||
|
||||
# ----- _make_key -----
|
||||
|
||||
def test_make_key_stable():
|
||||
p = {"q": "test", "engines": "google,bing", "format": "json"}
|
||||
k1 = cache._make_key(p)
|
||||
k2 = cache._make_key(p)
|
||||
assert k1 == k2
|
||||
assert len(k1) == 64 # SHA-256 hex digest length
|
||||
|
||||
|
||||
def test_make_key_excludes_format():
|
||||
"""format is transient (output shape, not result set) — must not affect key."""
|
||||
p1 = {"q": "test", "format": "json"}
|
||||
p2 = {"q": "test", "format": "brief"}
|
||||
assert cache._make_key(p1) == cache._make_key(p2)
|
||||
|
||||
|
||||
def test_make_key_differs_on_query():
|
||||
assert cache._make_key({"q": "python"}) != cache._make_key({"q": "rust"})
|
||||
|
||||
|
||||
def test_make_key_differs_on_engines():
|
||||
assert cache._make_key({"q": "x", "engines": "google"}) != \
|
||||
cache._make_key({"q": "x", "engines": "bing"})
|
||||
|
||||
|
||||
def test_make_key_differs_on_time_range():
|
||||
assert cache._make_key({"q": "x", "time_range": "day"}) != \
|
||||
cache._make_key({"q": "x", "time_range": "year"})
|
||||
|
||||
|
||||
# ----- get / put -----
|
||||
|
||||
def test_put_get_roundtrip(isolated_cache):
|
||||
params = {"q": "hello", "format": "json"}
|
||||
result = {"results": [{"title": "Hi", "url": "https://example.com"}],
|
||||
"number_of_results": 1}
|
||||
cache.put(params, result, ttl_seconds=60)
|
||||
got = cache.get(params, ttl_seconds=60)
|
||||
assert got is not None
|
||||
assert got["results"][0]["title"] == "Hi"
|
||||
|
||||
|
||||
def test_get_miss_when_empty(isolated_cache):
|
||||
assert cache.get({"q": "nope"}, ttl_seconds=60) is None
|
||||
|
||||
|
||||
def test_get_expired(isolated_cache):
|
||||
"""Entry older than TTL is a miss."""
|
||||
params = {"q": "old", "format": "json"}
|
||||
cache.put(params, {"results": []}, ttl_seconds=60)
|
||||
# Backdate the entry so it's past TTL
|
||||
with cache._connect(cache._cache_path()) as conn:
|
||||
conn.execute(
|
||||
"UPDATE search_cache SET created_at = ? WHERE key = ?",
|
||||
(time.time() - 120, cache._make_key(params)),
|
||||
)
|
||||
conn.commit()
|
||||
assert cache.get(params, ttl_seconds=60) is None
|
||||
|
||||
|
||||
def test_get_caller_shorter_ttl_expires(isolated_cache):
|
||||
"""Caller's shorter TTL overrides a longer stored TTL (immediate effect)."""
|
||||
params = {"q": "ttl-test", "format": "json"}
|
||||
cache.put(params, {"results": []}, ttl_seconds=3600)
|
||||
with cache._connect(cache._cache_path()) as conn:
|
||||
conn.execute(
|
||||
"UPDATE search_cache SET created_at = ? WHERE key = ?",
|
||||
(time.time() - 120, cache._make_key(params)),
|
||||
)
|
||||
conn.commit()
|
||||
# stored TTL=3600 (still fresh by stored clock) but caller asks 60s -> expired
|
||||
assert cache.get(params, ttl_seconds=60) is None
|
||||
|
||||
|
||||
def test_get_caller_longer_ttl_uses_stored(isolated_cache):
|
||||
"""Caller's longer TTL does NOT resurrect an expired-by-stored entry."""
|
||||
params = {"q": "ttl2", "format": "json"}
|
||||
cache.put(params, {"results": []}, ttl_seconds=60)
|
||||
with cache._connect(cache._cache_path()) as conn:
|
||||
conn.execute(
|
||||
"UPDATE search_cache SET created_at = ? WHERE key = ?",
|
||||
(time.time() - 120, cache._make_key(params)),
|
||||
)
|
||||
conn.commit()
|
||||
# stored TTL=60 (expired), caller asks 3600 -> effective = min(3600,60)=60 -> expired
|
||||
assert cache.get(params, ttl_seconds=3600) is None
|
||||
|
||||
|
||||
# ----- clear -----
|
||||
|
||||
def test_clear_removes_entries(isolated_cache):
|
||||
cache.put({"q": "x"}, {"results": []}, ttl_seconds=60)
|
||||
removed = cache.clear()
|
||||
assert removed == 1
|
||||
assert cache.get({"q": "x"}, ttl_seconds=60) is None
|
||||
|
||||
|
||||
def test_clear_empty_returns_zero(isolated_cache):
|
||||
assert cache.clear() == 0
|
||||
|
||||
|
||||
# ----- stats -----
|
||||
|
||||
def test_stats_reports_entries(isolated_cache):
|
||||
cache.put({"q": "stats-test"}, {"results": []}, ttl_seconds=60)
|
||||
s = cache.stats()
|
||||
assert s["entries"] == 1
|
||||
assert s["size_bytes"] > 0
|
||||
assert "path" in s
|
||||
assert s.get("oldest_created_at") is not None
|
||||
assert s.get("newest_created_at") is not None
|
||||
|
||||
|
||||
def test_stats_empty(isolated_cache):
|
||||
s = cache.stats()
|
||||
assert s["entries"] == 0
|
||||
|
||||
|
||||
# ----- ttl<=0 disable -----
|
||||
|
||||
def test_get_ttl_zero_is_disabled(isolated_cache):
|
||||
"""ttl_seconds=0 means caching off -> always miss even if stored."""
|
||||
cache.put({"q": "disabled"}, {"results": []}, ttl_seconds=60)
|
||||
assert cache.get({"q": "disabled"}, ttl_seconds=0) is None
|
||||
|
||||
|
||||
def test_put_ttl_zero_is_noop(isolated_cache):
|
||||
"""put with ttl<=0 should not store anything."""
|
||||
cache.put({"q": "noop"}, {"results": []}, ttl_seconds=0)
|
||||
assert cache.get({"q": "noop"}, ttl_seconds=60) is None
|
||||
assert cache.stats()["entries"] == 0
|
||||
Reference in New Issue
Block a user