feat(alarm): trex-core 알람 emitter + trex.alerts exchange (dcy-033 BAT-049, Implemented)
AlarmEmitter(application, best-effort 비차단)+trex.alerts topic exchange 가산·격리+8 CRITICAL code 배선. 608 passed, opus QC PASS, 팀장 e2e(텔레그램 HTTP200) 통과. REQ-098~101 Implemented. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHtM5Ws1HKCKQ4JGi6GRb5
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
"""InMemoryTransport stub tests (REQ-022 AC-1 + REQ-024 degrade)."""
|
||||
"""InMemoryTransport stub tests (REQ-022 AC-1 + REQ-024 degrade + REQ-099 alarm)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
@@ -86,3 +86,23 @@ def test_publish_event_with_no_subscriber_routes_to_dlq():
|
||||
dlq = t.dlq_messages()
|
||||
assert len(dlq) == 1
|
||||
assert dlq[0][0] == "slot_filled_buy.A.005930"
|
||||
|
||||
|
||||
# dcy-033 / REQ-099: alarm channel tests
|
||||
def test_publish_alarm_event_records_event():
|
||||
"""Alarm event is recorded in _alarm_events when transport is available."""
|
||||
t = InMemoryTransport()
|
||||
t.publish_alarm_event("alarm.critical.trex-core", {"code": "kill_switch_fired"})
|
||||
alarms = t.alarm_events()
|
||||
assert len(alarms) == 1
|
||||
rk, payload = alarms[0]
|
||||
assert rk == "alarm.critical.trex-core"
|
||||
assert payload["code"] == "kill_switch_fired"
|
||||
|
||||
|
||||
def test_publish_alarm_event_raises_when_unavailable():
|
||||
"""Alarm publish raises TransportUnavailableError when transport is down."""
|
||||
t = InMemoryTransport()
|
||||
t.set_available(False)
|
||||
with pytest.raises(TransportUnavailableError):
|
||||
t.publish_alarm_event("alarm.critical.trex-core", {"code": "test"})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Topology constants + routing key builders (REQ-021)."""
|
||||
"""Topology constants + routing key builders (REQ-021, REQ-099)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from trex_core.adapters.messaging import topology
|
||||
@@ -12,6 +12,17 @@ def test_topology_constants():
|
||||
assert not hasattr(topology, "SNAPSHOT_REQ_QUEUE")
|
||||
|
||||
|
||||
# dcy-033 / REQ-099: trex.alerts exchange constant + routing key builder
|
||||
def test_alerts_exchange_constant():
|
||||
assert topology.ALERTS_EXCHANGE == "trex.alerts"
|
||||
|
||||
|
||||
def test_alarm_routing_key_format():
|
||||
assert topology.alarm_routing_key("critical", "trex-core") == "alarm.critical.trex-core"
|
||||
assert topology.alarm_routing_key("warn", "trex-orb-v1") == "alarm.warn.trex-orb-v1"
|
||||
assert topology.alarm_routing_key("info", "kis-data-hub") == "alarm.info.kis-data-hub"
|
||||
|
||||
|
||||
def test_event_routing_key_format():
|
||||
assert topology.event_routing_key("slot_filled_buy", "A", "005930") == \
|
||||
"slot_filled_buy.A.005930"
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
"""AlarmEmitter unit tests (dcy-033 / REQ-100).
|
||||
|
||||
Coverage:
|
||||
- 8 CRITICAL code × (a) normal publish + envelope validation
|
||||
× (b) TransportUnavailableError → no re-raise + ERROR log
|
||||
- arbitrary exception swallowing
|
||||
- transport=None skip (reset.py / CLI path)
|
||||
- routing key format: alarm.critical.trex-core
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from trex_core.adapters.messaging.in_memory import InMemoryTransport
|
||||
from trex_core.application.alarm_emitter import AlarmEmitter
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
CRITICAL_CODES = [
|
||||
"fill_price_unresolvable",
|
||||
"sell_skipped_no_balance",
|
||||
"core_holding_broker_flat",
|
||||
"ledger_delivery_failed",
|
||||
"kill_switch_fired",
|
||||
"publish_unavailable_after_reconnect",
|
||||
"dlq_publish_failed",
|
||||
"portfolio_hub_unavailable",
|
||||
]
|
||||
|
||||
_ROUTING_KEY = "alarm.critical.trex-core"
|
||||
|
||||
|
||||
def _make_emitter() -> tuple[AlarmEmitter, InMemoryTransport]:
|
||||
transport = InMemoryTransport()
|
||||
return AlarmEmitter(transport), transport
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ envelope validation
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", CRITICAL_CODES)
|
||||
def test_publish_alarm_envelope_fields(code: str):
|
||||
"""(a) Normal publish: envelope contains all 7 required fields with correct values."""
|
||||
emitter, transport = _make_emitter()
|
||||
fields_in = {"slot_id": "abc", "symbol": "005930"}
|
||||
emitter.publish_alarm(code, f"Test alarm for {code}", fields=fields_in)
|
||||
|
||||
alarms = transport.alarm_events()
|
||||
assert len(alarms) == 1, f"Expected 1 alarm event for code={code}"
|
||||
|
||||
rk, payload = alarms[0]
|
||||
assert rk == _ROUTING_KEY, f"routing key mismatch: {rk}"
|
||||
|
||||
# REQ-098: all 7 envelope fields
|
||||
assert payload["source"] == "trex-core"
|
||||
assert payload["severity"] == "critical"
|
||||
assert payload["code"] == code
|
||||
assert payload["title"] == f"Test alarm for {code}"
|
||||
assert payload["fields"] == fields_in
|
||||
assert "occurred_at" in payload
|
||||
assert payload["schema_version"] == "1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", CRITICAL_CODES)
|
||||
def test_publish_alarm_transport_unavailable_no_reraise(
|
||||
code: str, caplog: pytest.LogCaptureFixture
|
||||
):
|
||||
"""(b) TransportUnavailableError must not propagate; ERROR log must appear."""
|
||||
emitter, transport = _make_emitter()
|
||||
transport.set_available(False)
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="trex_core.application.alarm_emitter"):
|
||||
# Must NOT raise
|
||||
emitter.publish_alarm(code, f"Test alarm for {code}")
|
||||
|
||||
# No alarms recorded (transport was down)
|
||||
assert transport.alarm_events() == []
|
||||
# ERROR log must have been emitted
|
||||
assert any("alarm publish" in r.message for r in caplog.records), (
|
||||
f"Expected ERROR log for code={code}"
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ additional edge cases
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", CRITICAL_CODES)
|
||||
def test_publish_alarm_arbitrary_exception_swallowed(code: str, caplog: pytest.LogCaptureFixture):
|
||||
"""Any exception (not just TransportUnavailableError) is swallowed + ERROR logged."""
|
||||
class BrokenTransport:
|
||||
def publish_alarm_event(self, routing_key, payload):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
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(code, "boom test") # Must NOT raise
|
||||
|
||||
assert any("alarm publish" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_publish_alarm_none_transport_skip(caplog: pytest.LogCaptureFixture):
|
||||
"""transport=None: publish is skipped with INFO log (reset.py / CLI path, REQ-100 KD-2)."""
|
||||
emitter = AlarmEmitter(None)
|
||||
with caplog.at_level(logging.INFO, logger="trex_core.application.alarm_emitter"):
|
||||
emitter.publish_alarm("kill_switch_fired", "no transport test")
|
||||
|
||||
assert any("alarm skipped" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_publish_alarm_empty_fields_default():
|
||||
"""fields defaults to {} when not provided — not None in payload."""
|
||||
emitter, transport = _make_emitter()
|
||||
emitter.publish_alarm("kill_switch_fired", "no fields")
|
||||
_, payload = transport.alarm_events()[0]
|
||||
assert payload["fields"] == {}
|
||||
|
||||
|
||||
def test_publish_alarm_routing_key_format():
|
||||
"""routing key must be alarm.critical.trex-core for default severity."""
|
||||
emitter, transport = _make_emitter()
|
||||
emitter.publish_alarm("kill_switch_fired", "routing key test")
|
||||
rk, _ = transport.alarm_events()[0]
|
||||
assert rk == "alarm.critical.trex-core"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ domain purity sweep
|
||||
|
||||
|
||||
def test_alarm_emitter_no_pika_import():
|
||||
"""AlarmEmitter module must not import pika (domain/application purity)."""
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
# Ensure pika is not in alarm_emitter's import chain at module level.
|
||||
mod = sys.modules.get("trex_core.application.alarm_emitter")
|
||||
if mod is None:
|
||||
mod = importlib.import_module("trex_core.application.alarm_emitter")
|
||||
|
||||
# pika should NOT be in the module's global namespace
|
||||
assert "pika" not in dir(mod), "alarm_emitter must not import pika directly"
|
||||
|
||||
|
||||
def test_alarm_emitter_no_telegram_reference():
|
||||
"""Verify no TELEGRAM_* references in alarm_emitter source (REQ-100 AC-5)."""
|
||||
import inspect
|
||||
import trex_core.application.alarm_emitter as ae_mod
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user