650 lines
23 KiB
Python
650 lines
23 KiB
Python
# =================== AIPass ====================
|
|
# Name: test_error_detected.py
|
|
# Description: Tests for error_detected event handler with Medic v2 dispatch gating
|
|
# Version: 1.0.0
|
|
# Created: 2026-04-25
|
|
# Modified: 2026-04-25
|
|
# =============================================
|
|
|
|
"""Tests for error_detected event handler: set_send_email_callback, handle_error_detected, and fallback stubs."""
|
|
|
|
import json
|
|
import sys
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixture: mocks config + json_handler, provides a registry-available
|
|
# environment by default. Individual tests override module-level helpers
|
|
# after importing.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_infrastructure(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
"""Mock config, json_handler, error_registry, and wake_branch before import."""
|
|
from aipass.trigger.apps.config import atomic_write_json
|
|
|
|
mock_config = MagicMock()
|
|
mock_config.TRIGGER_ROOT = tmp_path
|
|
mock_config.atomic_write_json = atomic_write_json
|
|
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.config", mock_config)
|
|
|
|
mock_json_handler = MagicMock()
|
|
mock_json_handler.log_operation = MagicMock(return_value=True)
|
|
json_pkg = MagicMock()
|
|
json_pkg.json_handler = mock_json_handler
|
|
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.json", json_pkg)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"aipass.trigger.apps.handlers.json.json_handler",
|
|
mock_json_handler,
|
|
)
|
|
|
|
# Provide a working error_registry mock so _REGISTRY_DISPATCH_AVAILABLE=True
|
|
mock_registry = MagicMock()
|
|
mock_registry.circuit_breaker_allows = MagicMock(return_value=True)
|
|
mock_registry.circuit_breaker_record_error = MagicMock()
|
|
mock_registry.should_dispatch = MagicMock(return_value=True)
|
|
mock_registry.record_dispatch = MagicMock()
|
|
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.error_registry", mock_registry)
|
|
|
|
# Mock wake_branch import chain to prevent real imports
|
|
mock_wake = MagicMock()
|
|
mock_wake.wake_branch = MagicMock()
|
|
monkeypatch.setitem(sys.modules, "aipass.ai_mail", MagicMock())
|
|
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps", MagicMock())
|
|
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers", MagicMock())
|
|
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers.dispatch", MagicMock())
|
|
monkeypatch.setitem(sys.modules, "aipass.ai_mail.apps.handlers.dispatch.wake", mock_wake)
|
|
|
|
monkeypatch.delitem(
|
|
sys.modules,
|
|
"aipass.trigger.apps.handlers.events.error_detected",
|
|
raising=False,
|
|
)
|
|
|
|
|
|
def _import_module():
|
|
"""Import error_detected module fresh after mocking."""
|
|
import aipass.trigger.apps.handlers.events.error_detected as m
|
|
|
|
return m
|
|
|
|
|
|
def _setup_happy_path(mod: object) -> MagicMock:
|
|
"""Patch module internals for a successful dispatch and return the send_email mock."""
|
|
send_mock = MagicMock(return_value=True)
|
|
mod._is_medic_enabled = MagicMock(return_value=True) # type: ignore[attr-defined]
|
|
mod._is_branch_muted = MagicMock(return_value=False) # type: ignore[attr-defined]
|
|
mod._get_registered_emails = MagicMock(return_value={"@flow", "@spawn"}) # type: ignore[attr-defined]
|
|
mod._send_email = send_mock # type: ignore[attr-defined]
|
|
mod.circuit_breaker_allows = MagicMock(return_value=True) # type: ignore[attr-defined]
|
|
mod.registry_should_dispatch = MagicMock(return_value=True) # type: ignore[attr-defined]
|
|
mod.registry_record_dispatch = MagicMock() # type: ignore[attr-defined]
|
|
mod.circuit_breaker_record_error = MagicMock() # type: ignore[attr-defined]
|
|
mod._REGISTRY_DISPATCH_AVAILABLE = True # type: ignore[attr-defined]
|
|
return send_mock
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# set_send_email_callback
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSetSendEmailCallback:
|
|
"""Tests for set_send_email_callback."""
|
|
|
|
def test_sets_callback(self) -> None:
|
|
"""Stores the callback as the module-level _send_email."""
|
|
mod = _import_module()
|
|
callback = MagicMock()
|
|
mod.set_send_email_callback(callback)
|
|
assert mod._send_email is callback
|
|
|
|
def test_overwrites_previous_callback(self) -> None:
|
|
"""Second call replaces the first callback."""
|
|
mod = _import_module()
|
|
first = MagicMock()
|
|
second = MagicMock()
|
|
mod.set_send_email_callback(first)
|
|
mod.set_send_email_callback(second)
|
|
assert mod._send_email is second
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# handle_error_detected -- early-return gates
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHandleErrorDetectedGates:
|
|
"""Tests for early-return gates in handle_error_detected."""
|
|
|
|
def test_returns_early_missing_branch(self) -> None:
|
|
"""Does not dispatch when branch is None."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
|
|
mod.handle_error_detected(branch=None, module="cfg", message="err", error_hash="h1", count=2)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_missing_module(self) -> None:
|
|
"""Does not dispatch when module is None."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
|
|
mod.handle_error_detected(branch="flow", module=None, message="err", error_hash="h1", count=2)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_missing_message(self) -> None:
|
|
"""Does not dispatch when message is None."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
|
|
mod.handle_error_detected(branch="flow", module="cfg", message=None, error_hash="h1", count=2)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_missing_error_hash(self) -> None:
|
|
"""Does not dispatch when error_hash is None."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
|
|
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash=None, count=2)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_medic_disabled(self) -> None:
|
|
"""Does not dispatch when medic is disabled."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
mod._is_medic_enabled = MagicMock(return_value=False) # type: ignore[attr-defined]
|
|
|
|
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_branch_muted(self) -> None:
|
|
"""Does not dispatch when branch is muted."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
mod._is_branch_muted = MagicMock(return_value=True) # type: ignore[attr-defined]
|
|
|
|
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_count_below_threshold(self) -> None:
|
|
"""Does not dispatch on first occurrence (count=1)."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
|
|
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=1)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_send_email_is_none(self) -> None:
|
|
"""Does not dispatch when _send_email callback was never set."""
|
|
mod = _import_module()
|
|
_setup_happy_path(mod)
|
|
mod._send_email = None # type: ignore[attr-defined]
|
|
|
|
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
|
|
|
|
def test_returns_early_devpulse_recipient(self) -> None:
|
|
"""Does not dispatch to @devpulse (protected branch)."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
mod._get_registered_emails = MagicMock(return_value={"@devpulse"}) # type: ignore[attr-defined]
|
|
|
|
mod.handle_error_detected(branch="devpulse", module="cfg", message="err", error_hash="h1", count=2)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_branch_not_in_registry(self) -> None:
|
|
"""Does not dispatch when branch email is not in the registry."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
mod._get_registered_emails = MagicMock(return_value={"@api", "@drone"}) # type: ignore[attr-defined]
|
|
|
|
mod.handle_error_detected(branch="flow", module="cfg", message="err", error_hash="h1", count=2)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_circuit_breaker_open(self) -> None:
|
|
"""Does not dispatch when circuit breaker is open."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
mod.circuit_breaker_allows = MagicMock(return_value=False) # type: ignore[attr-defined]
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="abc123",
|
|
)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_returns_early_should_dispatch_false(self) -> None:
|
|
"""Does not dispatch when per-fingerprint backoff rejects."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
mod.registry_should_dispatch = MagicMock(return_value=False) # type: ignore[attr-defined]
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="abc123",
|
|
)
|
|
|
|
send.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# handle_error_detected -- happy path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestHandleErrorDetectedHappyPath:
|
|
"""Tests for successful dispatch through handle_error_detected."""
|
|
|
|
def test_sends_email_with_correct_args(self) -> None:
|
|
"""Dispatches email to the correct recipient with auto_execute."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="config",
|
|
message="NullPointerError",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp123",
|
|
timestamp="2026-04-25 10:00:00",
|
|
)
|
|
|
|
send.assert_called_once()
|
|
kwargs = send.call_args[1]
|
|
assert kwargs["to_branch"] == "@flow"
|
|
assert kwargs["auto_execute"] is True
|
|
assert kwargs["reply_to"] == "@devpulse"
|
|
assert kwargs["from_branch"] == "@trigger"
|
|
|
|
def test_records_dispatch_after_send(self) -> None:
|
|
"""Calls registry_record_dispatch with the fingerprint after sending."""
|
|
mod = _import_module()
|
|
_setup_happy_path(mod)
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp456",
|
|
)
|
|
|
|
mod.registry_record_dispatch.assert_called_once_with("fp456") # type: ignore[attr-defined]
|
|
|
|
def test_logs_dispatch_sent(self) -> None:
|
|
"""Logs dispatch_sent via json_handler after successful send."""
|
|
mod = _import_module()
|
|
_setup_happy_path(mod)
|
|
from aipass.trigger.apps.handlers.json import json_handler
|
|
|
|
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp789",
|
|
)
|
|
|
|
json_handler.log_operation.assert_called_once_with( # type: ignore[union-attr]
|
|
"dispatch_sent", {"recipient": "@flow"}
|
|
)
|
|
|
|
def test_handles_send_exception_gracefully(self) -> None:
|
|
"""Does not raise when _send_email throws."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
send.side_effect = RuntimeError("SMTP down")
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fpX",
|
|
)
|
|
|
|
def test_does_not_record_dispatch_when_send_fails(self) -> None:
|
|
"""When _send_email returns False, dispatch is not recorded."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
send.return_value = False
|
|
from aipass.trigger.apps.handlers.json import json_handler
|
|
|
|
json_handler.log_operation.reset_mock() # type: ignore[union-attr]
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp_fail",
|
|
)
|
|
|
|
# Email was attempted
|
|
send.assert_called_once()
|
|
# But nothing after it should have run
|
|
json_handler.log_operation.assert_not_called() # type: ignore[union-attr]
|
|
mod.registry_record_dispatch.assert_not_called() # type: ignore[attr-defined]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fallback stubs (when error_registry import fails)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFallbackStubs:
|
|
"""Tests for fallback functions defined when error_registry is unavailable."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _force_registry_unavailable(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Set error_registry to None so the ImportError fallback triggers."""
|
|
monkeypatch.setitem(sys.modules, "aipass.trigger.apps.handlers.error_registry", None)
|
|
monkeypatch.delitem(
|
|
sys.modules,
|
|
"aipass.trigger.apps.handlers.events.error_detected",
|
|
raising=False,
|
|
)
|
|
|
|
def test_registry_should_dispatch_returns_true(self) -> None:
|
|
"""Fallback always allows dispatch for any fingerprint."""
|
|
mod = _import_module()
|
|
assert mod.registry_should_dispatch("any-fingerprint") is True
|
|
|
|
def test_registry_record_dispatch_does_not_raise(self) -> None:
|
|
"""Fallback record_dispatch is a no-op."""
|
|
mod = _import_module()
|
|
mod.registry_record_dispatch("any-fingerprint")
|
|
|
|
def test_circuit_breaker_allows_returns_true(self) -> None:
|
|
"""Fallback circuit breaker always allows."""
|
|
mod = _import_module()
|
|
assert mod.circuit_breaker_allows() is True
|
|
|
|
def test_circuit_breaker_record_error_does_not_raise(self) -> None:
|
|
"""Fallback circuit_breaker_record_error is a no-op."""
|
|
mod = _import_module()
|
|
mod.circuit_breaker_record_error()
|
|
|
|
def test_registry_dispatch_available_is_false(self) -> None:
|
|
"""Module reports registry dispatch as unavailable."""
|
|
mod = _import_module()
|
|
assert mod._REGISTRY_DISPATCH_AVAILABLE is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TTL-aware medic enable/disable
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestMedicEnabledTTL:
|
|
"""Tests for _is_medic_enabled TTL expiry behavior."""
|
|
|
|
def test_medic_enabled_ttl_expired(self) -> None:
|
|
"""medic_enabled=False with expired TTL -> treated as enabled, dispatch proceeds."""
|
|
mod = _import_module()
|
|
real_is_medic_enabled = mod._is_medic_enabled
|
|
send = _setup_happy_path(mod)
|
|
mod._is_medic_enabled = real_is_medic_enabled # type: ignore[attr-defined]
|
|
|
|
config_file = mod.TRIGGER_CONFIG_FILE
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
past = (datetime.now() - timedelta(hours=1)).isoformat()
|
|
config_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"config": {
|
|
"medic_enabled": False,
|
|
"medic_disabled_until": past,
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp_ttl_exp",
|
|
)
|
|
|
|
send.assert_called_once()
|
|
|
|
def test_medic_enabled_ttl_active(self) -> None:
|
|
"""medic_enabled=False with future TTL -> medic still disabled, dispatch suppressed."""
|
|
mod = _import_module()
|
|
real_is_medic_enabled = mod._is_medic_enabled
|
|
send = _setup_happy_path(mod)
|
|
mod._is_medic_enabled = real_is_medic_enabled # type: ignore[attr-defined]
|
|
|
|
config_file = mod.TRIGGER_CONFIG_FILE
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
future = (datetime.now() + timedelta(hours=1)).isoformat()
|
|
config_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"config": {
|
|
"medic_enabled": False,
|
|
"medic_disabled_until": future,
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp_ttl_act",
|
|
)
|
|
|
|
send.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Branch mute dict/string format support
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestBranchMutedFormats:
|
|
"""Tests for _is_branch_muted dict and string format support."""
|
|
|
|
def test_branch_muted_dict_format_active(self) -> None:
|
|
"""Dict entry with future expires_at -> branch IS muted, dispatch suppressed."""
|
|
mod = _import_module()
|
|
real_is_branch_muted = mod._is_branch_muted
|
|
send = _setup_happy_path(mod)
|
|
mod._is_branch_muted = real_is_branch_muted # type: ignore[attr-defined]
|
|
mod._get_registered_emails = MagicMock(return_value={"@api", "@flow"}) # type: ignore[attr-defined]
|
|
|
|
config_file = mod.TRIGGER_CONFIG_FILE
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
future = (datetime.now() + timedelta(hours=1)).isoformat()
|
|
config_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"config": {
|
|
"medic_enabled": True,
|
|
"muted_branches": [{"name": "api", "expires_at": future}],
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
mod.handle_error_detected(
|
|
branch="api",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp_mute_act",
|
|
)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_branch_muted_dict_format_expired(self) -> None:
|
|
"""Dict entry with past expires_at -> branch NOT muted, dispatch proceeds."""
|
|
mod = _import_module()
|
|
real_is_branch_muted = mod._is_branch_muted
|
|
send = _setup_happy_path(mod)
|
|
mod._is_branch_muted = real_is_branch_muted # type: ignore[attr-defined]
|
|
mod._get_registered_emails = MagicMock(return_value={"@api", "@flow"}) # type: ignore[attr-defined]
|
|
|
|
config_file = mod.TRIGGER_CONFIG_FILE
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
past = (datetime.now() - timedelta(hours=1)).isoformat()
|
|
config_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"config": {
|
|
"medic_enabled": True,
|
|
"muted_branches": [{"name": "api", "expires_at": past}],
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
mod.handle_error_detected(
|
|
branch="api",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp_mute_exp",
|
|
)
|
|
|
|
send.assert_called_once()
|
|
|
|
def test_branch_muted_plain_string_backcompat(self) -> None:
|
|
"""Plain string entry in muted_branches -> branch IS muted (permanent)."""
|
|
mod = _import_module()
|
|
real_is_branch_muted = mod._is_branch_muted
|
|
send = _setup_happy_path(mod)
|
|
mod._is_branch_muted = real_is_branch_muted # type: ignore[attr-defined]
|
|
mod._get_registered_emails = MagicMock(return_value={"@api", "@flow"}) # type: ignore[attr-defined]
|
|
|
|
config_file = mod.TRIGGER_CONFIG_FILE
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"config": {
|
|
"medic_enabled": True,
|
|
"muted_branches": ["api"],
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
mod.handle_error_detected(
|
|
branch="api",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp_str_perm",
|
|
)
|
|
|
|
send.assert_not_called()
|
|
|
|
def test_branch_muted_dict_permanent(self) -> None:
|
|
"""Dict entry with expires_at=null -> branch IS muted (permanent)."""
|
|
mod = _import_module()
|
|
real_is_branch_muted = mod._is_branch_muted
|
|
send = _setup_happy_path(mod)
|
|
mod._is_branch_muted = real_is_branch_muted # type: ignore[attr-defined]
|
|
mod._get_registered_emails = MagicMock(return_value={"@api", "@flow"}) # type: ignore[attr-defined]
|
|
|
|
config_file = mod.TRIGGER_CONFIG_FILE
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"config": {
|
|
"medic_enabled": True,
|
|
"muted_branches": [{"name": "api", "expires_at": None}],
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
mod.handle_error_detected(
|
|
branch="api",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp_dict_perm",
|
|
)
|
|
|
|
send.assert_not_called()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# circuit_breaker_probe_succeeded after dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestProbeSucceeded:
|
|
"""Tests for circuit_breaker_probe_succeeded called after dispatch."""
|
|
|
|
def test_probe_succeeded_called_after_dispatch(self) -> None:
|
|
"""circuit_breaker_probe_succeeded is called after successful dispatch with fingerprint."""
|
|
mod = _import_module()
|
|
send = _setup_happy_path(mod)
|
|
mod.circuit_breaker_probe_succeeded = MagicMock() # type: ignore[attr-defined]
|
|
|
|
mod.handle_error_detected(
|
|
branch="flow",
|
|
module="cfg",
|
|
message="err",
|
|
error_hash="h1",
|
|
count=2,
|
|
fingerprint="fp_probe",
|
|
)
|
|
|
|
send.assert_called_once()
|
|
mod.registry_record_dispatch.assert_called_once_with("fp_probe") # type: ignore[attr-defined]
|
|
mod.circuit_breaker_probe_succeeded.assert_called_once() # type: ignore[attr-defined]
|