feat(alerts): publish reconciliation quantity mismatches to trex-notifier (req-0007/ac-0024)

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-08-25 14:50:29 +00:00
committed by GitHub
co-authored by genichin
parent baae02d2af
commit 153a563f6d
6 changed files with 1458 additions and 52 deletions
@@ -0,0 +1,841 @@
"""Reconciliation mismatch notification lifecycle tests (req-0007, ac-0024).
Verifies B-case and C-case detected/changed/resolved alarm publication with
durable deduplication via ReconcileNotificationStore, including:
- first detection
- unchanged suppression (within and across simulated restarts)
- quantity change (changed event)
- multi-portfolio C-case aggregation (one event per symbol)
- B-case slot identity isolation
- B sync resolution
- C verify resolution
- publish failure: fingerprint not advanced, retry next detection
- resolved publish failure: warning clear preserved, pending_resolved set
- notification-state persistence failure: business path not aborted
- duplicate resolved: idempotent
- sensitive-field absence
"""
from __future__ import annotations
from unittest.mock import MagicMock
from uuid import uuid4
from trex_core.adapters.messaging.in_memory import InMemoryTransport
from trex_core.application.alarm_emitter import AlarmEmitter
from trex_core.application.broker_port import BalanceItem
from trex_core.application.portfolio import (
InMemoryReconcileNotificationStore,
InMemoryReconcileWarningStore,
PortfolioItem,
SnapshotStore,
)
from trex_core.application.reconciliation import (
_c_identity_key,
reconcile_core_holdings,
reconcile_positions,
sync_slot_to_broker,
verify_slot_reconcile,
)
from trex_core.domain.slot import SlotService, SlotState
from trex_core.domain.slot.in_memory_repository import InMemorySlotRepository
from trex_core.domain.slot.models import Slot
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
INSTANCE = "test-instance"
def _make_emitter() -> tuple[AlarmEmitter, InMemoryTransport]:
transport = InMemoryTransport()
return AlarmEmitter(transport), transport
def _make_failing_emitter() -> AlarmEmitter:
"""AlarmEmitter backed by a transport that always raises."""
transport = InMemoryTransport()
transport.set_available(False)
return AlarmEmitter(transport)
def _alarm_codes(transport: InMemoryTransport) -> list[str]:
return [payload["code"] for _, payload in transport.alarm_events()]
def _alarm_fields_for(transport: InMemoryTransport, code: str) -> list[dict]:
return [
payload["fields"]
for _, payload in transport.alarm_events()
if payload["code"] == code
]
def _make_slot(
status: SlotState = SlotState.HOLDING,
qty: float = 10.0,
symbol: str = "005930",
pid: str = "pf-A",
) -> Slot:
return Slot(
slot_id=uuid4(),
status=status,
symbol=symbol,
qty=qty,
portfolio_id=pid,
instance_key=INSTANCE,
)
def _snapshot(portfolio_id: str, asset_qty: float, symbol: str = "005930") -> SnapshotStore:
store = SnapshotStore()
store.swap([PortfolioItem(
portfolio_id=portfolio_id,
symbol=symbol,
cash_balance=0.0,
asset_quantity=asset_qty,
)])
return store
class FakeBroker:
def __init__(self, balances: dict[str, int]):
self._b = balances
def name(self) -> str:
return "fake"
def submit_order(self, req): ...
def poll_fill(self, *args, **kw): ...
def cancel_order(self, *args, **kw): ...
def get_current_price(self, sym): return None
def get_balance(self) -> list[BalanceItem]:
return [BalanceItem(symbol=sym, hldg_qty=qty)
for sym, qty in self._b.items() if qty > 0]
def _repo_with_slot(slot: Slot) -> InMemorySlotRepository:
repo = InMemorySlotRepository()
repo.add_slot(slot)
return repo
def _run_reconcile_positions(
hub_qty: int,
broker_qty: int,
symbol: str = "005930",
pid: str = "pf-A",
emitter: AlarmEmitter | None = None,
notification_store=None,
warn_store=None,
slot_repo=None,
) -> dict:
store = _snapshot(pid, float(hub_qty), symbol=symbol)
broker = FakeBroker({symbol: broker_qty})
return reconcile_positions(
store=store,
broker=broker,
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=notification_store,
warn_store=warn_store,
slot_repo=slot_repo,
)
# ---------------------------------------------------------------------------
# B-case: reconcile_core_holdings
# ---------------------------------------------------------------------------
def test_b_case_first_detection_publishes_detected():
"""New B-case slot mismatch → detected event published once."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
slot_service = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo,
slot_service=slot_service,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=ns,
)
codes = _alarm_codes(transport)
assert codes == ["reconcile_mismatch_detected"]
state = ns.get_state(str(slot.slot_id))
assert state is not None
assert state["lifecycle_state"] == "notified"
assert state["quantity_a"] == 10
assert state["quantity_b"] == 7
def test_b_case_unchanged_suppression():
"""Repeated tick with same qty tuple → no second event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
slot_service = SlotService(repo)
for _ in range(3):
reconcile_core_holdings(
slot_repo=repo,
slot_service=slot_service,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=ns,
)
assert len(transport.alarm_events()) == 1
def test_b_case_process_restart_suppression():
"""After restart (new store seeded with old fingerprint), unchanged tick is suppressed."""
emitter1, transport1 = _make_emitter()
ns1 = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter1, notification_store=ns1,
)
assert len(transport1.alarm_events()) == 1
# Simulate restart: new emitter + new in-memory store, but seed it from ns1 state.
emitter2, transport2 = _make_emitter()
ns2 = InMemoryReconcileNotificationStore()
state = ns1.get_state(slot_id_str)
ns2.set_state(slot_id_str, **{k: v for k, v in state.items() if k != "identity_key"})
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter2, notification_store=ns2,
)
assert len(transport2.alarm_events()) == 0 # suppressed
def test_b_case_quantity_change_publishes_changed():
"""B-case same slot, different qty tuple → changed event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
# Now broker qty changes → different fingerprint
# We need to update the slot qty in-place to simulate a changed qty scenario.
# Manually update notification state to simulate a different prior fingerprint.
ns.set_state(str(slot.slot_id), case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
# New broker qty: 5 instead of 7
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 5}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
codes = _alarm_codes(transport)
assert "reconcile_mismatch_changed" in codes
state = ns.get_state(str(slot.slot_id))
assert state["quantity_b"] == 5
def test_b_case_separate_slots_no_fingerprint_mixing():
"""Different B-case slot identities have independent fingerprints."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot_a = _make_slot(qty=10.0, symbol="005930", pid="pf-A")
slot_b = _make_slot(qty=20.0, symbol="000660", pid="pf-B")
repo = InMemorySlotRepository()
repo.add_slot(slot_a)
repo.add_slot(slot_b)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7, "000660": 15}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
# Both should get detected events
codes = _alarm_codes(transport)
assert codes.count("reconcile_mismatch_detected") == 2
state_a = ns.get_state(str(slot_a.slot_id))
state_b = ns.get_state(str(slot_b.slot_id))
assert state_a is not None
assert state_b is not None
# Fingerprints are independent
assert state_a["quantity_a"] == 10
assert state_b["quantity_a"] == 20
assert state_a["identity_key"] != state_b["identity_key"]
def test_b_case_publish_failure_fingerprint_not_advanced():
"""Transport failure → fingerprint not advanced → retry on next detection."""
failing_emitter = _make_failing_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=failing_emitter, notification_store=ns,
)
# Fingerprint must not be advanced
assert ns.get_state(str(slot.slot_id)) is None
# Next detection should try again (detected, not changed)
emitter2, transport2 = _make_emitter()
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter2, notification_store=ns,
)
assert _alarm_codes(transport2) == ["reconcile_mismatch_detected"]
def test_b_case_no_notification_without_store():
"""Backward compat: no notification_store → no crash, no notification."""
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
emitter, transport = _make_emitter()
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
# no notification_store
)
assert str(slot.slot_id) in decisions
assert len(transport.alarm_events()) == 0
# ---------------------------------------------------------------------------
# B-case resolved: sync_slot_to_broker
# ---------------------------------------------------------------------------
def test_b_sync_resolution_publishes_resolved():
"""operator sync clears warning → resolved event published."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
# Seed notification state (simulating prior detection)
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo,
slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
codes = _alarm_codes(transport)
assert "reconcile_mismatch_resolved" in codes
# State cleared after resolved
assert ns.get_state(slot_id_str) is None
def test_b_sync_resolved_publish_failure_warning_clear_preserved():
"""Resolved publish fails → warning clear is preserved, pending_resolved set."""
failing_emitter = _make_failing_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
result = sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo,
slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=failing_emitter,
notification_store=ns,
)
# Warning clear preserved
assert result["warning_cleared"] is True
assert not ws.is_warned(slot_id_str)
# pending_resolved set for retry
state = ns.get_state(slot_id_str)
assert state is not None
assert state["lifecycle_state"] == "pending_resolved"
def test_b_sync_resolved_retry_on_next_reconcile():
"""After pending_resolved set, next reconcile pass retries resolved publish."""
ns = InMemoryReconcileNotificationStore()
slot_id_str = "some-slot-id"
# Manually set pending_resolved state (warning already cleared)
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="pending_resolved")
emitter, transport = _make_emitter()
slot = _make_slot(qty=7.0) # qty matches broker now — not in active mismatch set
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
codes = _alarm_codes(transport)
assert "reconcile_mismatch_resolved" in codes
assert ns.get_state(slot_id_str) is None
def test_b_sync_duplicate_resolved_idempotent():
"""No notification state → resolved is a no-op (idempotent)."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
# No notification state seeded — simulate never-detected
sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo,
slot_service=ss,
broker=FakeBroker({"005930": 7}), # qty 10 != 7 so sync updates
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
# resolved alarm must not be published when no prior state
resolved = [c for c in _alarm_codes(transport) if c == "reconcile_mismatch_resolved"]
assert len(resolved) == 0
# ---------------------------------------------------------------------------
# C-case: reconcile_positions
# ---------------------------------------------------------------------------
def test_c_case_first_detection_publishes_detected():
"""New C-case symbol aggregate mismatch → detected event published once."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
_run_reconcile_positions(
hub_qty=10, broker_qty=7,
emitter=emitter, notification_store=ns,
)
assert _alarm_codes(transport) == ["reconcile_mismatch_detected"]
c_key = _c_identity_key(INSTANCE, "005930")
state = ns.get_state(c_key)
assert state is not None
assert state["case_code"] == "C"
assert state["quantity_a"] == 10
assert state["quantity_b"] == 7
def test_c_case_multi_portfolio_single_event():
"""C-case multiple portfolios for same symbol → exactly one detected event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
# pf-A has 6, pf-B has 4 → hub total 10 ≠ broker 7
store = SnapshotStore()
store.swap([
PortfolioItem(portfolio_id="pf-A", symbol="005930", cash_balance=0, asset_quantity=6.0),
PortfolioItem(portfolio_id="pf-B", symbol="005930", cash_balance=0, asset_quantity=4.0),
])
broker = FakeBroker({"005930": 7})
reconcile_positions(
store=store,
broker=broker,
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=ns,
)
assert len(transport.alarm_events()) == 1
assert _alarm_codes(transport) == ["reconcile_mismatch_detected"]
# Only one state entry for the symbol
c_key = _c_identity_key(INSTANCE, "005930")
state = ns.get_state(c_key)
assert state is not None
assert state["quantity_a"] == 10 # 6 + 4
def test_c_case_unchanged_suppression():
"""Repeated C-case ticks with same qty tuple → no second event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
for _ in range(3):
_run_reconcile_positions(hub_qty=10, broker_qty=7, emitter=emitter, notification_store=ns)
assert len(transport.alarm_events()) == 1
def test_c_case_quantity_change_publishes_changed():
"""C-case qty tuple changes → changed event."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
_run_reconcile_positions(hub_qty=10, broker_qty=7, emitter=emitter, notification_store=ns)
assert _alarm_codes(transport) == ["reconcile_mismatch_detected"]
# Broker qty changes
_run_reconcile_positions(hub_qty=10, broker_qty=5, emitter=emitter, notification_store=ns)
codes = _alarm_codes(transport)
assert "reconcile_mismatch_changed" in codes
def test_c_case_publish_failure_fingerprint_not_advanced():
"""Transport failure → fingerprint not advanced → retry on next detection."""
failing_emitter = _make_failing_emitter()
ns = InMemoryReconcileNotificationStore()
_run_reconcile_positions(
hub_qty=10, broker_qty=7, emitter=failing_emitter, notification_store=ns,
)
c_key = _c_identity_key(INSTANCE, "005930")
assert ns.get_state(c_key) is None
# Retry with working emitter
emitter2, transport2 = _make_emitter()
_run_reconcile_positions(hub_qty=10, broker_qty=7, emitter=emitter2, notification_store=ns)
assert _alarm_codes(transport2) == ["reconcile_mismatch_detected"]
def test_c_case_no_notification_without_store():
"""Backward compat: no alarm_emitter/notification_store → no crash."""
decisions = _run_reconcile_positions(hub_qty=10, broker_qty=7)
assert "pf-A" in decisions
# ---------------------------------------------------------------------------
# C-case resolved: verify_slot_reconcile
# ---------------------------------------------------------------------------
def test_c_verify_resolution_publishes_resolved():
"""operator verify clears C-case warning → resolved event published."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="C")
# Seed notification state
c_key = _c_identity_key(INSTANCE, "005930")
ns.set_state(c_key, case_code="C", instance_key=INSTANCE,
symbol="005930", quantity_a=8, quantity_b=10, lifecycle_state="notified")
store = _snapshot("pf-A", 10.0) # hub 10 == broker 10 → match
verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=FakeBroker({"005930": 10}),
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
assert "reconcile_mismatch_resolved" in _alarm_codes(transport)
assert ns.get_state(c_key) is None
def test_c_verify_resolved_publish_failure_warning_clear_preserved():
"""Resolved publish fails → warning clear preserved, pending_resolved set."""
failing_emitter = _make_failing_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="C")
c_key = _c_identity_key(INSTANCE, "005930")
ns.set_state(c_key, case_code="C", instance_key=INSTANCE,
symbol="005930", quantity_a=8, quantity_b=10, lifecycle_state="notified")
store = _snapshot("pf-A", 10.0)
result = verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=FakeBroker({"005930": 10}),
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=failing_emitter,
notification_store=ns,
)
# Warning cleared
assert result["result"] == "cleared"
assert not ws.is_warned(slot_id_str)
# pending_resolved set
state = ns.get_state(c_key)
assert state is not None
assert state["lifecycle_state"] == "pending_resolved"
def test_c_verify_duplicate_resolved_idempotent():
"""No prior notification state → verify resolved is a no-op."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="C")
store = _snapshot("pf-A", 10.0)
verify_slot_reconcile(
portfolio_id="pf-A",
store=store,
broker=FakeBroker({"005930": 10}),
slot_repo=repo,
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter,
notification_store=ns,
)
resolved = [c for c in _alarm_codes(transport) if c == "reconcile_mismatch_resolved"]
assert len(resolved) == 0
def test_c_verify_resolved_retry_on_next_reconcile():
"""pending_resolved → next reconcile_positions pass retries resolved publish."""
ns = InMemoryReconcileNotificationStore()
c_key = _c_identity_key(INSTANCE, "005930")
ns.set_state(c_key, case_code="C", instance_key=INSTANCE,
symbol="005930", quantity_a=8, quantity_b=10, lifecycle_state="pending_resolved")
emitter, transport = _make_emitter()
# Simulate match (hub == broker) so no active mismatch
_run_reconcile_positions(hub_qty=10, broker_qty=10, emitter=emitter, notification_store=ns)
assert "reconcile_mismatch_resolved" in _alarm_codes(transport)
assert ns.get_state(c_key) is None
# ---------------------------------------------------------------------------
# Notification-state persistence failure: business path not aborted
# ---------------------------------------------------------------------------
def test_notification_state_persistence_failure_does_not_abort_reconcile():
"""notification_store raises → reconciliation completes normally."""
bad_ns = MagicMock()
bad_ns.get_state.side_effect = RuntimeError("db down")
bad_ns.set_state.side_effect = RuntimeError("db down")
emitter, _transport = _make_emitter()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
decisions = reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
notification_store=bad_ns,
)
# Business path: decisions produced, no crash
assert str(slot.slot_id) in decisions
assert decisions[str(slot.slot_id)] == "qty_warning"
def test_notification_state_persistence_failure_c_case_does_not_abort_reconcile():
"""C-case notification_store raises → reconcile_positions completes normally."""
bad_ns = MagicMock()
bad_ns.get_state.side_effect = RuntimeError("db down")
bad_ns.list_states_by_lifecycle.side_effect = RuntimeError("db down")
emitter, _transport = _make_emitter()
decisions = _run_reconcile_positions(
hub_qty=10, broker_qty=7, emitter=emitter, notification_store=bad_ns,
)
assert "pf-A" in decisions
# ---------------------------------------------------------------------------
# Sensitive-field absence
# ---------------------------------------------------------------------------
def test_b_case_alarm_fields_no_sensitive_data():
"""B-case alarm fields contain only allowlist values — no account/credential/DSN."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter, notification_store=ns,
)
events = transport.alarm_events()
assert len(events) == 1
_, payload = events[0]
fields = payload["fields"]
# Allowlist: instance_key, symbol, case_code, broker_quantity, slot_quantity, slot_id
allowed = {"instance_key", "symbol", "case_code", "broker_quantity", "slot_quantity", "slot_id"}
extra = set(fields.keys()) - allowed
assert extra == set(), f"Unexpected fields: {extra}"
# No sensitive values
all_values = " ".join(str(v) for v in fields.values())
for forbidden in ("account", "credential", "token", "dsn", "postgresql", "amqp", "secret"):
assert forbidden not in all_values.lower(), f"Sensitive token '{forbidden}' found in fields"
def test_c_case_alarm_fields_no_sensitive_data():
"""C-case alarm fields contain only allowlist values."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
_run_reconcile_positions(hub_qty=10, broker_qty=7, emitter=emitter, notification_store=ns)
_, payload = transport.alarm_events()[0]
fields = payload["fields"]
allowed = {"instance_key", "symbol", "case_code", "broker_quantity", "hub_quantity"}
extra = set(fields.keys()) - allowed
assert extra == set(), f"Unexpected fields: {extra}"
# hub_quantity present, slot_quantity absent
assert "hub_quantity" in fields
assert "slot_quantity" not in fields
def test_resolved_alarm_fields_b_case():
"""Resolved B-case alarm fields include allowlist values only."""
emitter, transport = _make_emitter()
ns = InMemoryReconcileNotificationStore()
slot = _make_slot(qty=10.0)
slot_id_str = str(slot.slot_id)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
ws.set_warned(slot_id_str, portfolio_id="pf-A", case_code="B")
ns.set_state(slot_id_str, case_code="B", instance_key=INSTANCE,
symbol="005930", quantity_a=10, quantity_b=7, lifecycle_state="notified")
sync_slot_to_broker(
slot_id=slot_id_str,
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
warn_store=ws,
alarm_emitter=emitter, notification_store=ns,
)
resolved_events = [
(rk, p) for rk, p in transport.alarm_events()
if p["code"] == "reconcile_mismatch_resolved"
]
assert len(resolved_events) == 1
_, payload = resolved_events[0]
fields = payload["fields"]
allowed = {"instance_key", "symbol", "case_code", "broker_quantity", "slot_quantity", "slot_id"}
extra = set(fields.keys()) - allowed
assert extra == set(), f"Unexpected fields: {extra}"
# ---------------------------------------------------------------------------
# Existing behavior preservation
# ---------------------------------------------------------------------------
def test_b_case_existing_alarm_emitter_still_works_without_notification_store():
"""AlarmEmitter existing callers: no notification_store → no regression."""
emitter, transport = _make_emitter()
slot = _make_slot(qty=10.0)
repo = _repo_with_slot(slot)
ss = SlotService(repo)
ws = InMemoryReconcileWarningStore()
reconcile_core_holdings(
slot_repo=repo, slot_service=ss,
broker=FakeBroker({"005930": 7}),
instance_key=INSTANCE,
alarm_emitter=emitter,
warn_store=ws,
)
# No alarm from B-case without notification_store (only existing A-case alarm path unchanged)
assert len(transport.alarm_events()) == 0
assert ws.is_warned(str(slot.slot_id))
def test_publish_alarm_returns_bool():
"""AlarmEmitter.publish_alarm returns True on success, False on failure."""
emitter, _ = _make_emitter()
result = emitter.publish_alarm("test_code", "Test title")
assert result is True
failing = _make_failing_emitter()
result2 = failing.publish_alarm("test_code", "Test title")
assert result2 is False