Files
trex-core/tests/adapters/broker/test_token_cache.py
T
genichinandClaude Opus 4.8 ac6061f510 Deliver BAT-005: KIS token Redis cache (REQ-016, minor-change)
Port the trex-orbit Redis token-cache pattern so the KIS access token
survives daemon restarts and is shared across processes, avoiding KIS
token-issuance rate limits. KISBroker._ensure_token now resolves L1
in-memory -> L2 cache -> issue, storing issued tokens back to the cache.

- adapters/broker/token_cache.py: TokenCache protocol + RedisTokenCache
  (key trex:broker:kis:{account_ref}:token, token+expiry+app_key suffix
  JSON, ex=ttl, best-effort with graceful fallback) + InMemoryTokenCache.
- kis.py: optional token_cache injection.
- runtime config/daemon: TREX_KIS_REDIS_URL wires a RedisTokenCache.
- pyproject: redis>=5.

Verification: 96 passed, ruff clean — cache hit avoids issuance, cache is
shared across instances (restart/multi-process survival), backend errors
fall back to issuance (no regression), and app_key-suffix mismatch
invalidates a stale cache. Tested deterministically with an in-memory
double (no Redis server required). Processed via the minor-change fast
path: REQ-016 -> Implemented, BAT-005 (batch-lite) -> release-candidate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:44:09 +09:00

89 lines
2.8 KiB
Python

"""KIS token cache tests (REQ-016).
Uses InMemoryTokenCache as a deterministic double + a stubbed token endpoint
(httpx.post monkeypatched) so no real Redis server or network is needed.
"""
from __future__ import annotations
import httpx
import pytest
from trex_core.adapters.broker.kis import KISBroker
from trex_core.adapters.broker.token_cache import InMemoryTokenCache
_MOCK_URL = "https://openapivts.koreainvestment.com:29443"
class _TokenResp:
status_code = 200
def __init__(self, token: str) -> None:
self._token = token
def raise_for_status(self) -> None:
pass
def json(self) -> dict:
return {"access_token": self._token, "expires_in": 86400}
@pytest.fixture
def count_token_calls(monkeypatch):
calls = {"n": 0}
def fake_post(url, **kwargs):
assert url.endswith("/oauth2/tokenP")
calls["n"] += 1
return _TokenResp(f"TOK-{calls['n']}")
monkeypatch.setattr(httpx, "post", fake_post)
return calls
def _broker(cache, app_key="appkey0001"):
return KISBroker("12345678-01", app_key=app_key, app_secret="s",
base_url=_MOCK_URL, token_cache=cache)
def test_cache_hit_avoids_issuance(count_token_calls):
cache = InMemoryTokenCache()
b = _broker(cache)
t1 = b._ensure_token() # L3 issue + store
assert count_token_calls["n"] == 1
b._token = None # drop L1 to force L2 lookup
t2 = b._ensure_token() # L2 cache hit — no new issuance
assert t2 == t1 and count_token_calls["n"] == 1
def test_shared_cache_across_instances(count_token_calls):
cache = InMemoryTokenCache()
a = _broker(cache)
tok = a._ensure_token() # instance A issues + stores
assert count_token_calls["n"] == 1
b = _broker(cache) # instance B (e.g. after restart) shares cache
assert b._ensure_token() == tok and count_token_calls["n"] == 1
def test_graceful_fallback_when_cache_errors(count_token_calls):
# RedisTokenCache swallows backend errors and returns None on miss; this
# double models that post-swallow behaviour, so the broker still issues.
class SilentCache:
def load(self, *a, **k):
return None
def store(self, *a, **k):
return None
b = _broker(SilentCache())
assert b._ensure_token() == "TOK-1" and count_token_calls["n"] == 1
def test_app_key_suffix_mismatch_ignores_cache(count_token_calls):
cache = InMemoryTokenCache()
a = _broker(cache, app_key="AAAA1111")
a._ensure_token() # cached under suffix 1111
assert count_token_calls["n"] == 1
b = _broker(cache, app_key="BBBB2222") # rotated credential, suffix 2222
b._ensure_token() # must NOT reuse stale cache -> re-issue
assert count_token_calls["n"] == 2