mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-29 16:17:25 +09:00
The fetch -> TTL -> last-good -> bundled pipeline that four services re-implemented (and that produced the bundled-index and proxy-rewrite incidents) now lives once in utils/remote_index. The extension registry and theme registry become thin normalizers over it; the registry gains the failure-retry throttling the theme copy had already hardened to (last-good under full TTL, bundled held only error_ttl). Deliberate non-adopters, documented: template_service (keyed per-repo cache with targeted invalidation) and security_feed (DB-durable last-good for a daily job). Engine unit tests pin the semantics; registry/theme/signing suites stay green (test seeds updated to the shared cache seam). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
141 lines
4.7 KiB
Python
141 lines
4.7 KiB
Python
"""Plan 77 F1 — CachedRemoteIndex: the one remote-catalog engine.
|
|
|
|
No network: the fetcher is monkeypatched. These pin the hardened semantics
|
|
(last-good under full TTL after a failure; bundled held only error_ttl) that
|
|
the per-service copies used to drift on.
|
|
"""
|
|
import json
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from app.utils.remote_index import CachedRemoteIndex
|
|
|
|
|
|
def _normalize(payload, base_url):
|
|
items = payload.get('items') if isinstance(payload, dict) else None
|
|
if not isinstance(items, list):
|
|
return []
|
|
return [{'slug': x['slug'], 'base': base_url} for x in items if x.get('slug')]
|
|
|
|
|
|
def _index(tmp_path, monkeypatch, env=None, bundled=None, **kwargs):
|
|
if env is not None:
|
|
monkeypatch.setenv('X_INDEX_URL', env)
|
|
else:
|
|
monkeypatch.delenv('X_INDEX_URL', raising=False)
|
|
bundled_path = None
|
|
if bundled is not None:
|
|
bundled_path = tmp_path / 'bundled.json'
|
|
bundled_path.write_text(json.dumps(bundled), encoding='utf-8')
|
|
return CachedRemoteIndex(
|
|
name='X', env_var='X_INDEX_URL',
|
|
default_url='https://example.test/index.json',
|
|
normalize_fn=_normalize,
|
|
bundled_path=str(bundled_path) if bundled_path else None,
|
|
bundled_base_url='bundled://',
|
|
ttl=100, error_ttl=10,
|
|
**kwargs,
|
|
)
|
|
|
|
|
|
class _Fetch:
|
|
def __init__(self, results):
|
|
self.results = list(results)
|
|
self.calls = 0
|
|
|
|
def __call__(self):
|
|
self.calls += 1
|
|
result = self.results.pop(0) if self.results else self.results_last
|
|
if isinstance(result, Exception):
|
|
raise result
|
|
self.results_last = result
|
|
return result
|
|
|
|
|
|
def test_success_caches_within_ttl(tmp_path, monkeypatch):
|
|
idx = _index(tmp_path, monkeypatch)
|
|
fetch = _Fetch([[{'slug': 'a', 'base': 'https://example.test/index.json'}]])
|
|
monkeypatch.setattr(idx, '_fetch_remote', fetch)
|
|
first = idx.get()
|
|
assert first[0]['slug'] == 'a'
|
|
assert idx.source_label() == 'remote'
|
|
assert idx.get() is first
|
|
assert fetch.calls == 1
|
|
|
|
|
|
def test_force_refetches(tmp_path, monkeypatch):
|
|
idx = _index(tmp_path, monkeypatch)
|
|
fetch = _Fetch([[{'slug': 'a'}], [{'slug': 'b'}]])
|
|
monkeypatch.setattr(idx, '_fetch_remote', fetch)
|
|
idx.get()
|
|
assert idx.get(force=True)[0]['slug'] == 'b'
|
|
assert fetch.calls == 2
|
|
|
|
|
|
def test_ttl_expiry_refetches(tmp_path, monkeypatch):
|
|
idx = _index(tmp_path, monkeypatch)
|
|
fetch = _Fetch([[{'slug': 'a'}], [{'slug': 'b'}]])
|
|
monkeypatch.setattr(idx, '_fetch_remote', fetch)
|
|
idx.get()
|
|
idx._cache['ts'] -= idx.ttl + 1
|
|
assert idx.get()[0]['slug'] == 'b'
|
|
|
|
|
|
def test_failure_serves_last_good_under_full_ttl(tmp_path, monkeypatch):
|
|
idx = _index(tmp_path, monkeypatch)
|
|
fetch = _Fetch([[{'slug': 'good'}], RuntimeError('down')])
|
|
monkeypatch.setattr(idx, '_fetch_remote', fetch)
|
|
idx.get()
|
|
idx._cache['ts'] -= idx.ttl + 1
|
|
served = idx.get()
|
|
assert served[0]['slug'] == 'good'
|
|
assert fetch.calls == 2
|
|
# last-good is stamped with the full TTL — the next call must NOT retry
|
|
idx.get()
|
|
assert fetch.calls == 2
|
|
|
|
|
|
def test_failure_with_nothing_serves_bundled_for_error_ttl_only(tmp_path, monkeypatch):
|
|
idx = _index(tmp_path, monkeypatch,
|
|
bundled={'items': [{'slug': 'bundled-entry'}]})
|
|
fetch = _Fetch([RuntimeError('down'), [{'slug': 'live'}]])
|
|
monkeypatch.setattr(idx, '_fetch_remote', fetch)
|
|
served = idx.get()
|
|
assert served[0]['slug'] == 'bundled-entry'
|
|
assert idx.source_label() == 'bundled'
|
|
# inside the error_ttl window: no retry
|
|
assert idx.get()[0]['slug'] == 'bundled-entry'
|
|
assert fetch.calls == 1
|
|
# after the error_ttl window (but well under the full TTL): retried
|
|
idx._cache['ts'] -= idx.error_ttl + 1
|
|
assert idx.get()[0]['slug'] == 'live'
|
|
assert fetch.calls == 2
|
|
|
|
|
|
def test_bundled_normalize_uses_bundled_base_url(tmp_path, monkeypatch):
|
|
idx = _index(tmp_path, monkeypatch, env='', # disabled: bundled only
|
|
bundled={'items': [{'slug': 's'}]})
|
|
served = idx.get()
|
|
assert served[0]['base'] == 'bundled://'
|
|
assert idx.source_label() == 'bundled'
|
|
|
|
|
|
def test_env_url_resolution(tmp_path, monkeypatch):
|
|
idx = _index(tmp_path, monkeypatch)
|
|
assert idx.url() == 'https://example.test/index.json'
|
|
monkeypatch.setenv('X_INDEX_URL', 'https://override.test/i.json')
|
|
assert idx.url() == 'https://override.test/i.json'
|
|
monkeypatch.setenv('X_INDEX_URL', '')
|
|
assert idx.url() == ''
|
|
|
|
|
|
def test_invalidate_clears_cache(tmp_path, monkeypatch):
|
|
idx = _index(tmp_path, monkeypatch)
|
|
fetch = _Fetch([[{'slug': 'a'}], [{'slug': 'b'}]])
|
|
monkeypatch.setattr(idx, '_fetch_remote', fetch)
|
|
idx.get()
|
|
idx.invalidate()
|
|
assert idx.source_label() is None
|
|
assert idx.get()[0]['slug'] == 'b'
|