fix(intents): isolate alarm context lookup and sanitize alarm failure logs
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
5dd3d57f86
commit
1ca9e60c98
@@ -15,6 +15,7 @@ import pytest
|
||||
|
||||
from trex_core.adapters.messaging.in_memory import InMemoryTransport
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
from trex_core.application.messaging import TransportUnavailableError
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
@@ -154,3 +155,40 @@ def test_alarm_emitter_no_telegram_reference():
|
||||
source = inspect.getsource(ae_mod)
|
||||
assert "TELEGRAM" not in source, "alarm_emitter must not reference TELEGRAM_*"
|
||||
assert "telegram" not in source.lower(), "alarm_emitter must not reference telegram"
|
||||
|
||||
|
||||
def test_publish_alarm_transport_unavailable_log_is_sanitized(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
class UnavailableTransport:
|
||||
def publish_alarm_event(self, routing_key, payload):
|
||||
raise TransportUnavailableError("secret-token-value")
|
||||
|
||||
def health(self):
|
||||
from trex_core.application.messaging import TransportHealth
|
||||
return TransportHealth.DISCONNECTED
|
||||
|
||||
emitter = AlarmEmitter(UnavailableTransport()) # type: ignore[arg-type]
|
||||
with caplog.at_level(logging.ERROR, logger="trex_core.application.alarm_emitter"):
|
||||
emitter.publish_alarm("strategy_intent_rejected", "alarm title")
|
||||
|
||||
assert any("transport unavailable" in r.message for r in caplog.records)
|
||||
assert "secret-token-value" not in caplog.text
|
||||
|
||||
|
||||
def test_publish_alarm_generic_exception_log_is_sanitized(caplog: pytest.LogCaptureFixture):
|
||||
class BrokenTransport:
|
||||
def publish_alarm_event(self, routing_key, payload):
|
||||
raise RuntimeError("******")
|
||||
|
||||
def health(self):
|
||||
from trex_core.application.messaging import TransportHealth
|
||||
return TransportHealth.CONNECTED
|
||||
|
||||
emitter = AlarmEmitter(BrokenTransport()) # type: ignore[arg-type]
|
||||
with caplog.at_level(logging.ERROR, logger="trex_core.application.alarm_emitter"):
|
||||
emitter.publish_alarm("strategy_intent_rejected", "alarm title")
|
||||
|
||||
assert any("alarm publish failed" in r.message for r in caplog.records)
|
||||
assert "RuntimeError" in caplog.text
|
||||
assert "******" not in caplog.text
|
||||
|
||||
@@ -101,6 +101,24 @@ class DuplicateInFlightIntentStore:
|
||||
raise AssertionError("finalize must not be called for duplicate_in_flight")
|
||||
|
||||
|
||||
class ContextFailingSlotRepository(InMemorySlotRepository):
|
||||
"""Raise only on the N+1th fetch_slot to simulate post-finalize lookup failure."""
|
||||
|
||||
def __init__(self, *, fail_on_fetch_call_no=None):
|
||||
super().__init__()
|
||||
self._fetch_calls = 0
|
||||
self._fail_on_fetch_call_no = fail_on_fetch_call_no
|
||||
|
||||
def fetch_slot(self, slot_id):
|
||||
self._fetch_calls += 1
|
||||
if self._fail_on_fetch_call_no == self._fetch_calls:
|
||||
raise RuntimeError("context lookup failed")
|
||||
return super().fetch_slot(slot_id)
|
||||
|
||||
def fail_on_next_fetch(self):
|
||||
self._fail_on_fetch_call_no = self._fetch_calls + 1
|
||||
|
||||
|
||||
def _make_holding_slot(repo, svc, instance_key="default", portfolio_id="pf-A", symbol="005930", qty=10):
|
||||
"""Helper: creates universe, puts slot in HOLDING state with given qty."""
|
||||
repo.reconcile_universe(instance_key, {portfolio_id: symbol})
|
||||
@@ -512,6 +530,60 @@ def test_reconcile_warned_alarm_finalize_failure_and_publish_failure_are_isolate
|
||||
assert d3.reason == "reconcile_warned"
|
||||
|
||||
|
||||
def test_reconcile_warned_buy_context_lookup_failure_does_not_change_response():
|
||||
repo = ContextFailingSlotRepository(fail_on_fetch_call_no=2)
|
||||
svc = SlotService(repo)
|
||||
warn_store = InMemoryReconcileWarningStore()
|
||||
intent_store = InMemoryIntentStore()
|
||||
alarms = RecordingAlarmEmitter()
|
||||
repo.reconcile_universe("default", {"pf-A": "005930"})
|
||||
slot = repo.find_slot_by_portfolio("default", "pf-A")
|
||||
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="B")
|
||||
intent_svc = IntentService(
|
||||
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
|
||||
)
|
||||
|
||||
decision = intent_svc.submit_buy(
|
||||
BuyIntent(intent_id="buy-context-fail", slot_id=slot.slot_id, strategy_id="stg-buy")
|
||||
)
|
||||
assert decision.status == "rejected"
|
||||
assert decision.reason == "reconcile_warned"
|
||||
assert len(alarms.calls) == 1
|
||||
_, _, _, fields = alarms.calls[0]
|
||||
assert fields["intent_id"] == "buy-context-fail"
|
||||
assert fields["strategy_id"] == "stg-buy"
|
||||
assert "slot_id" not in fields
|
||||
assert "portfolio_id" not in fields
|
||||
assert "symbol" not in fields
|
||||
|
||||
|
||||
def test_reconcile_warned_sell_context_lookup_failure_does_not_change_response():
|
||||
repo = ContextFailingSlotRepository()
|
||||
svc = SlotService(repo)
|
||||
warn_store = InMemoryReconcileWarningStore()
|
||||
intent_store = InMemoryIntentStore()
|
||||
alarms = RecordingAlarmEmitter()
|
||||
slot = _make_holding_slot(repo, svc, qty=10, portfolio_id="pf-A", symbol="005930")
|
||||
repo._fail_on_fetch_call_no = repo._fetch_calls + 2
|
||||
warn_store.set_warned(str(slot.slot_id), portfolio_id="pf-A", case_code="C")
|
||||
intent_svc = IntentService(
|
||||
svc, repo, intent_store, instance_key="default", warn_store=warn_store, alarm_emitter=alarms
|
||||
)
|
||||
|
||||
decision = intent_svc.submit_sell(
|
||||
SellIntent(intent_id="sell-context-fail", slot_id=slot.slot_id, sell_strategy_id="stg-sell")
|
||||
)
|
||||
assert decision.status == "rejected"
|
||||
assert decision.reason == "reconcile_warned"
|
||||
assert len(alarms.calls) == 1
|
||||
_, _, _, fields = alarms.calls[0]
|
||||
assert fields["intent_id"] == "sell-context-fail"
|
||||
assert fields["strategy_id"] == "stg-sell"
|
||||
assert "slot_id" not in fields
|
||||
assert "portfolio_id" not in fields
|
||||
assert "symbol" not in fields
|
||||
|
||||
|
||||
# REQ-104 AC-3: no warn, no pause => buy/sell admitted normally
|
||||
def test_intent_service_no_warn_admitted_normally():
|
||||
repo = InMemorySlotRepository()
|
||||
|
||||
Reference in New Issue
Block a user