nodus-adapter-base 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: nodus-adapter-base
3
+ Version: 0.1.0
4
+ Summary: Abstract base for nodus channel adapters: reconnect loop, health recording, connection manager
5
+ Author: Shawn Knight
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Masterplanner25/nodus-adapters
8
+ Project-URL: Repository, https://github.com/Masterplanner25/nodus-adapters
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: nodus-channels>=0.1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
15
+
16
+ # nodus-adapter-base
17
+
18
+ **Abstract base class for [nodus-channels](https://github.com/Masterplanner25/nodus-channels) adapters.**
19
+
20
+ Provides the reconnect loop, health recording, and connection management
21
+ so concrete adapters (Slack, Discord, webhook, etc.) only implement the
22
+ three transport-specific methods.
23
+
24
+ > **Status:** v0.1.0 — prepared, not yet published.
25
+
26
+ ---
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install nodus-adapter-base
32
+ ```
33
+
34
+ Requires `nodus-channels>=0.1.0`.
35
+
36
+ ---
37
+
38
+ ## What it provides
39
+
40
+ | Class | Purpose |
41
+ |---|---|
42
+ | `BaseChannelAdapter` | Abstract base with `connect()` retry backoff, health recording, `send()`, `subscribe()` |
43
+ | `ConnectionManager` | `start_all()`, `stop_all()`, `health_check_all()` across a `ChannelRegistry` |
44
+
45
+ ---
46
+
47
+ ## Implementing an adapter
48
+
49
+ ```python
50
+ from nodus_channels import ChannelInfo, Message
51
+ from nodus_adapter_base import BaseChannelAdapter
52
+
53
+ class SlackAdapter(BaseChannelAdapter):
54
+ @property
55
+ def channel_id(self) -> str:
56
+ return "slack"
57
+
58
+ @property
59
+ def info(self) -> ChannelInfo:
60
+ return ChannelInfo(id="slack", display_name="Slack")
61
+
62
+ async def _do_connect(self) -> None:
63
+ # establish websocket / API connection
64
+ ...
65
+
66
+ async def _do_send(self, content, peer_id, *, thread_id=None,
67
+ reply_to_id=None, attachments=None) -> str:
68
+ # send message; return message ID
69
+ return "slack-msg-id"
70
+
71
+ def _do_subscribe(self):
72
+ # return an async generator yielding Message objects
73
+ async def _gen():
74
+ ...
75
+ yield message
76
+ return _gen()
77
+ ```
78
+
79
+ ### Reconnect backoff
80
+
81
+ `connect()` retries up to `max_reconnect_attempts` times (default 5) using
82
+ the backoff schedule `[1, 5, 30, 60, 300]` seconds. Each failure is recorded
83
+ on the `HealthMonitor`. Raises the last exception if all attempts fail.
84
+
85
+ ### Health recording
86
+
87
+ Every `connect()`, `send()`, and `health_check()` call records success or
88
+ failure on the adapter's `HealthMonitor`. Access it via `adapter.health_monitor`.
89
+
90
+ ---
91
+
92
+ ## ConnectionManager
93
+
94
+ ```python
95
+ from nodus_channels import ChannelRegistry
96
+ from nodus_adapter_base import ConnectionManager
97
+
98
+ registry = ChannelRegistry()
99
+ registry.register(SlackAdapter())
100
+ registry.register(DiscordAdapter())
101
+
102
+ manager = ConnectionManager(registry)
103
+ results = await manager.start_all() # {"slack": True, "discord": True}
104
+ health = await manager.health_check_all() # {"slack": True, "discord": False}
105
+ await manager.stop_all()
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ cd base
114
+ pip install -e ".[dev]"
115
+ pytest tests/ -q
116
+ ```
117
+
118
+ ---
119
+
120
+ ## License
121
+
122
+ MIT — see [LICENSE](../LICENSE).
@@ -0,0 +1,107 @@
1
+ # nodus-adapter-base
2
+
3
+ **Abstract base class for [nodus-channels](https://github.com/Masterplanner25/nodus-channels) adapters.**
4
+
5
+ Provides the reconnect loop, health recording, and connection management
6
+ so concrete adapters (Slack, Discord, webhook, etc.) only implement the
7
+ three transport-specific methods.
8
+
9
+ > **Status:** v0.1.0 — prepared, not yet published.
10
+
11
+ ---
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ pip install nodus-adapter-base
17
+ ```
18
+
19
+ Requires `nodus-channels>=0.1.0`.
20
+
21
+ ---
22
+
23
+ ## What it provides
24
+
25
+ | Class | Purpose |
26
+ |---|---|
27
+ | `BaseChannelAdapter` | Abstract base with `connect()` retry backoff, health recording, `send()`, `subscribe()` |
28
+ | `ConnectionManager` | `start_all()`, `stop_all()`, `health_check_all()` across a `ChannelRegistry` |
29
+
30
+ ---
31
+
32
+ ## Implementing an adapter
33
+
34
+ ```python
35
+ from nodus_channels import ChannelInfo, Message
36
+ from nodus_adapter_base import BaseChannelAdapter
37
+
38
+ class SlackAdapter(BaseChannelAdapter):
39
+ @property
40
+ def channel_id(self) -> str:
41
+ return "slack"
42
+
43
+ @property
44
+ def info(self) -> ChannelInfo:
45
+ return ChannelInfo(id="slack", display_name="Slack")
46
+
47
+ async def _do_connect(self) -> None:
48
+ # establish websocket / API connection
49
+ ...
50
+
51
+ async def _do_send(self, content, peer_id, *, thread_id=None,
52
+ reply_to_id=None, attachments=None) -> str:
53
+ # send message; return message ID
54
+ return "slack-msg-id"
55
+
56
+ def _do_subscribe(self):
57
+ # return an async generator yielding Message objects
58
+ async def _gen():
59
+ ...
60
+ yield message
61
+ return _gen()
62
+ ```
63
+
64
+ ### Reconnect backoff
65
+
66
+ `connect()` retries up to `max_reconnect_attempts` times (default 5) using
67
+ the backoff schedule `[1, 5, 30, 60, 300]` seconds. Each failure is recorded
68
+ on the `HealthMonitor`. Raises the last exception if all attempts fail.
69
+
70
+ ### Health recording
71
+
72
+ Every `connect()`, `send()`, and `health_check()` call records success or
73
+ failure on the adapter's `HealthMonitor`. Access it via `adapter.health_monitor`.
74
+
75
+ ---
76
+
77
+ ## ConnectionManager
78
+
79
+ ```python
80
+ from nodus_channels import ChannelRegistry
81
+ from nodus_adapter_base import ConnectionManager
82
+
83
+ registry = ChannelRegistry()
84
+ registry.register(SlackAdapter())
85
+ registry.register(DiscordAdapter())
86
+
87
+ manager = ConnectionManager(registry)
88
+ results = await manager.start_all() # {"slack": True, "discord": True}
89
+ health = await manager.health_check_all() # {"slack": True, "discord": False}
90
+ await manager.stop_all()
91
+ ```
92
+
93
+ ---
94
+
95
+ ## Development
96
+
97
+ ```bash
98
+ cd base
99
+ pip install -e ".[dev]"
100
+ pytest tests/ -q
101
+ ```
102
+
103
+ ---
104
+
105
+ ## License
106
+
107
+ MIT — see [LICENSE](../LICENSE).
@@ -0,0 +1,14 @@
1
+ """nodus-adapter-base — abstract base for channel adapters.
2
+
3
+ BaseChannelAdapter:
4
+ - connect() with exponential backoff retry
5
+ - Health recording via HealthMonitor
6
+ - Abstract interface: _do_connect, _do_send, _do_subscribe
7
+
8
+ ConnectionManager:
9
+ - start_all() / stop_all() / health_check_all()
10
+ """
11
+ from .adapter import BaseChannelAdapter
12
+ from .manager import ConnectionManager
13
+
14
+ __all__ = ["BaseChannelAdapter", "ConnectionManager"]
@@ -0,0 +1,148 @@
1
+ """BaseChannelAdapter — abstract base with reconnect loop and health recording."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from abc import ABC, abstractmethod
7
+ from typing import AsyncIterator, Optional
8
+
9
+ from nodus_channels import Attachment, ChannelInfo, ChannelRegistry, Message
10
+ from nodus_channels.health import HealthMonitor
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ _RECONNECT_BACKOFF: list[float] = [1.0, 5.0, 30.0, 60.0, 300.0]
15
+
16
+
17
+ class BaseChannelAdapter(ABC):
18
+ """Abstract base class for channel adapters.
19
+
20
+ Provides:
21
+ - ``connect()`` with exponential backoff retry
22
+ - Health recording via ``HealthMonitor``
23
+ - Default ``health_check()`` delegating to ``_do_health_check()``
24
+
25
+ Subclass this and implement ``_do_connect``, ``_do_send``,
26
+ ``_do_subscribe``, and optionally ``_do_health_check``.
27
+
28
+ Usage::
29
+
30
+ class MyAdapter(BaseChannelAdapter):
31
+ @property
32
+ def channel_id(self): return "my-channel"
33
+ @property
34
+ def info(self): return ChannelInfo("my-channel", "My Channel")
35
+ async def _do_connect(self): ...
36
+ async def _do_send(self, content, peer_id, **kwargs): return "msg-id"
37
+ def _do_subscribe(self): ...
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ *,
43
+ health_monitor: Optional[HealthMonitor] = None,
44
+ max_reconnect_attempts: int = 5,
45
+ ) -> None:
46
+ self._health = health_monitor or HealthMonitor()
47
+ self._max_reconnect = max_reconnect_attempts
48
+ self._connected = False
49
+
50
+ # ── Abstract interface ────────────────────────────────────────────────────
51
+
52
+ @property
53
+ @abstractmethod
54
+ def channel_id(self) -> str: ...
55
+
56
+ @property
57
+ @abstractmethod
58
+ def info(self) -> ChannelInfo: ...
59
+
60
+ @abstractmethod
61
+ async def _do_connect(self) -> None: ...
62
+
63
+ @abstractmethod
64
+ async def _do_send(
65
+ self,
66
+ content: str,
67
+ peer_id: str,
68
+ *,
69
+ thread_id: Optional[str] = None,
70
+ reply_to_id: Optional[str] = None,
71
+ attachments: Optional[list[Attachment]] = None,
72
+ ) -> str: ...
73
+
74
+ @abstractmethod
75
+ def _do_subscribe(self) -> AsyncIterator[Message]: ...
76
+
77
+ # ── Implemented methods ───────────────────────────────────────────────────
78
+
79
+ async def connect(self) -> None:
80
+ """Connect with exponential backoff retry on failure."""
81
+ for attempt, delay in enumerate(_RECONNECT_BACKOFF[: self._max_reconnect]):
82
+ try:
83
+ await self._do_connect()
84
+ self._connected = True
85
+ self._health.record_success(self.channel_id)
86
+ logger.info("[%s] connected", self.channel_id)
87
+ return
88
+ except Exception as exc:
89
+ self._health.record_failure(self.channel_id, str(exc))
90
+ if attempt < self._max_reconnect - 1:
91
+ logger.warning(
92
+ "[%s] connect failed (attempt %d): %s — retry in %.1fs",
93
+ self.channel_id, attempt + 1, exc, delay,
94
+ )
95
+ await asyncio.sleep(delay)
96
+ else:
97
+ logger.error("[%s] connect failed after %d attempts: %s",
98
+ self.channel_id, self._max_reconnect, exc)
99
+ raise
100
+
101
+ async def disconnect(self) -> None:
102
+ self._connected = False
103
+ logger.info("[%s] disconnected", self.channel_id)
104
+
105
+ async def send(
106
+ self,
107
+ content: str,
108
+ peer_id: str,
109
+ *,
110
+ thread_id: Optional[str] = None,
111
+ reply_to_id: Optional[str] = None,
112
+ attachments: Optional[list[Attachment]] = None,
113
+ ) -> str:
114
+ try:
115
+ msg_id = await self._do_send(
116
+ content, peer_id,
117
+ thread_id=thread_id,
118
+ reply_to_id=reply_to_id,
119
+ attachments=attachments,
120
+ )
121
+ self._health.record_success(self.channel_id)
122
+ return msg_id
123
+ except Exception as exc:
124
+ self._health.record_failure(self.channel_id, str(exc))
125
+ raise
126
+
127
+ def subscribe(self) -> AsyncIterator[Message]:
128
+ return self._do_subscribe()
129
+
130
+ async def health_check(self) -> bool:
131
+ try:
132
+ result = await self._do_health_check()
133
+ if result:
134
+ self._health.record_success(self.channel_id)
135
+ else:
136
+ self._health.record_failure(self.channel_id, "health check returned False")
137
+ return result
138
+ except Exception as exc:
139
+ self._health.record_failure(self.channel_id, str(exc))
140
+ return False
141
+
142
+ async def _do_health_check(self) -> bool:
143
+ """Override to provide a real health check. Default: True if connected."""
144
+ return self._connected
145
+
146
+ @property
147
+ def health_monitor(self) -> HealthMonitor:
148
+ return self._health
@@ -0,0 +1,65 @@
1
+ """ConnectionManager — start and stop all registered channel adapters."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import logging
6
+ from typing import Optional
7
+
8
+ from nodus_channels import ChannelRegistry
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class ConnectionManager:
14
+ """Start and gracefully stop all adapters in a ``ChannelRegistry``.
15
+
16
+ Usage::
17
+
18
+ registry = ChannelRegistry()
19
+ registry.register(MyAdapter())
20
+ manager = ConnectionManager(registry)
21
+ await manager.start_all()
22
+ # ... run forever ...
23
+ await manager.stop_all()
24
+ """
25
+
26
+ def __init__(self, registry: ChannelRegistry) -> None:
27
+ self._registry = registry
28
+
29
+ async def start_all(self) -> dict[str, bool]:
30
+ """Connect all registered adapters concurrently.
31
+
32
+ Returns a dict of ``{channel_id: success}`` for each adapter.
33
+ """
34
+ adapters = self._registry.list()
35
+ results: dict[str, bool] = {}
36
+ for adapter in adapters:
37
+ try:
38
+ await adapter.connect()
39
+ results[adapter.channel_id] = True
40
+ except Exception as exc:
41
+ logger.error("[ConnectionManager] failed to connect %s: %s",
42
+ adapter.channel_id, exc)
43
+ results[adapter.channel_id] = False
44
+ return results
45
+
46
+ async def stop_all(self) -> None:
47
+ """Disconnect all registered adapters."""
48
+ adapters = self._registry.list()
49
+ for adapter in adapters:
50
+ try:
51
+ await adapter.disconnect()
52
+ except Exception as exc:
53
+ logger.warning("[ConnectionManager] error disconnecting %s: %s",
54
+ adapter.channel_id, exc)
55
+
56
+ async def health_check_all(self) -> dict[str, bool]:
57
+ """Run health_check() on all adapters. Returns {channel_id: healthy}."""
58
+ adapters = self._registry.list()
59
+ results: dict[str, bool] = {}
60
+ for adapter in adapters:
61
+ try:
62
+ results[adapter.channel_id] = await adapter.health_check()
63
+ except Exception:
64
+ results[adapter.channel_id] = False
65
+ return results
@@ -0,0 +1,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: nodus-adapter-base
3
+ Version: 0.1.0
4
+ Summary: Abstract base for nodus channel adapters: reconnect loop, health recording, connection manager
5
+ Author: Shawn Knight
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Masterplanner25/nodus-adapters
8
+ Project-URL: Repository, https://github.com/Masterplanner25/nodus-adapters
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: nodus-channels>=0.1.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
15
+
16
+ # nodus-adapter-base
17
+
18
+ **Abstract base class for [nodus-channels](https://github.com/Masterplanner25/nodus-channels) adapters.**
19
+
20
+ Provides the reconnect loop, health recording, and connection management
21
+ so concrete adapters (Slack, Discord, webhook, etc.) only implement the
22
+ three transport-specific methods.
23
+
24
+ > **Status:** v0.1.0 — prepared, not yet published.
25
+
26
+ ---
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install nodus-adapter-base
32
+ ```
33
+
34
+ Requires `nodus-channels>=0.1.0`.
35
+
36
+ ---
37
+
38
+ ## What it provides
39
+
40
+ | Class | Purpose |
41
+ |---|---|
42
+ | `BaseChannelAdapter` | Abstract base with `connect()` retry backoff, health recording, `send()`, `subscribe()` |
43
+ | `ConnectionManager` | `start_all()`, `stop_all()`, `health_check_all()` across a `ChannelRegistry` |
44
+
45
+ ---
46
+
47
+ ## Implementing an adapter
48
+
49
+ ```python
50
+ from nodus_channels import ChannelInfo, Message
51
+ from nodus_adapter_base import BaseChannelAdapter
52
+
53
+ class SlackAdapter(BaseChannelAdapter):
54
+ @property
55
+ def channel_id(self) -> str:
56
+ return "slack"
57
+
58
+ @property
59
+ def info(self) -> ChannelInfo:
60
+ return ChannelInfo(id="slack", display_name="Slack")
61
+
62
+ async def _do_connect(self) -> None:
63
+ # establish websocket / API connection
64
+ ...
65
+
66
+ async def _do_send(self, content, peer_id, *, thread_id=None,
67
+ reply_to_id=None, attachments=None) -> str:
68
+ # send message; return message ID
69
+ return "slack-msg-id"
70
+
71
+ def _do_subscribe(self):
72
+ # return an async generator yielding Message objects
73
+ async def _gen():
74
+ ...
75
+ yield message
76
+ return _gen()
77
+ ```
78
+
79
+ ### Reconnect backoff
80
+
81
+ `connect()` retries up to `max_reconnect_attempts` times (default 5) using
82
+ the backoff schedule `[1, 5, 30, 60, 300]` seconds. Each failure is recorded
83
+ on the `HealthMonitor`. Raises the last exception if all attempts fail.
84
+
85
+ ### Health recording
86
+
87
+ Every `connect()`, `send()`, and `health_check()` call records success or
88
+ failure on the adapter's `HealthMonitor`. Access it via `adapter.health_monitor`.
89
+
90
+ ---
91
+
92
+ ## ConnectionManager
93
+
94
+ ```python
95
+ from nodus_channels import ChannelRegistry
96
+ from nodus_adapter_base import ConnectionManager
97
+
98
+ registry = ChannelRegistry()
99
+ registry.register(SlackAdapter())
100
+ registry.register(DiscordAdapter())
101
+
102
+ manager = ConnectionManager(registry)
103
+ results = await manager.start_all() # {"slack": True, "discord": True}
104
+ health = await manager.health_check_all() # {"slack": True, "discord": False}
105
+ await manager.stop_all()
106
+ ```
107
+
108
+ ---
109
+
110
+ ## Development
111
+
112
+ ```bash
113
+ cd base
114
+ pip install -e ".[dev]"
115
+ pytest tests/ -q
116
+ ```
117
+
118
+ ---
119
+
120
+ ## License
121
+
122
+ MIT — see [LICENSE](../LICENSE).
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ nodus_adapter_base/__init__.py
4
+ nodus_adapter_base/adapter.py
5
+ nodus_adapter_base/manager.py
6
+ nodus_adapter_base.egg-info/PKG-INFO
7
+ nodus_adapter_base.egg-info/SOURCES.txt
8
+ nodus_adapter_base.egg-info/dependency_links.txt
9
+ nodus_adapter_base.egg-info/requires.txt
10
+ nodus_adapter_base.egg-info/top_level.txt
11
+ tests/test_adapter_base.py
@@ -0,0 +1,5 @@
1
+ nodus-channels>=0.1.0
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ pytest-asyncio>=0.21
@@ -0,0 +1 @@
1
+ nodus_adapter_base
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nodus-adapter-base"
7
+ version = "0.1.0"
8
+ description = "Abstract base for nodus channel adapters: reconnect loop, health recording, connection manager"
9
+ authors = [{ name = "Shawn Knight" }]
10
+ license = { text = "MIT" }
11
+ readme = "README.md"
12
+ requires-python = ">=3.11"
13
+ dependencies = ["nodus-channels>=0.1.0"]
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.21"]
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/Masterplanner25/nodus-adapters"
20
+ Repository = "https://github.com/Masterplanner25/nodus-adapters"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["."]
24
+ include = ["nodus_adapter_base*"]
25
+
26
+ [tool.pytest.ini_options]
27
+ asyncio_mode = "auto"
28
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,148 @@
1
+ """nodus-adapter-base tests."""
2
+ import asyncio
3
+ import pytest
4
+
5
+ from nodus_channels import ChannelInfo, ChannelRegistry, Message
6
+ from nodus_adapter_base import BaseChannelAdapter, ConnectionManager
7
+
8
+
9
+ class _StubAdapter(BaseChannelAdapter):
10
+ """Minimal concrete adapter for testing."""
11
+
12
+ def __init__(self, channel="test", fail_connect=False, **kw):
13
+ super().__init__(**kw)
14
+ self._channel = channel
15
+ self._fail_connect = fail_connect
16
+ self.connect_calls = 0
17
+ self.send_calls = []
18
+
19
+ @property
20
+ def channel_id(self): return self._channel
21
+
22
+ @property
23
+ def info(self): return ChannelInfo(id=self._channel, display_name=self._channel.title())
24
+
25
+ async def _do_connect(self):
26
+ self.connect_calls += 1
27
+ if self._fail_connect:
28
+ raise ConnectionError("deliberate failure")
29
+
30
+ async def _do_send(self, content, peer_id, **kwargs):
31
+ self.send_calls.append((content, peer_id))
32
+ return f"msg-{len(self.send_calls)}"
33
+
34
+ def _do_subscribe(self):
35
+ async def _gen():
36
+ if False: yield # empty async generator
37
+ return _gen()
38
+
39
+ async def _do_health_check(self): return self._connected
40
+
41
+
42
+ # ── connect / disconnect ──────────────────────────────────────────────────────
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_connect_sets_connected():
46
+ adapter = _StubAdapter()
47
+ await adapter.connect()
48
+ assert adapter._connected is True
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_connect_records_health_success():
53
+ adapter = _StubAdapter()
54
+ await adapter.connect()
55
+ snap = adapter.health_monitor.snapshot("test")
56
+ assert snap.failure_count == 0
57
+
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_connect_failure_records_health():
61
+ adapter = _StubAdapter(fail_connect=True, max_reconnect_attempts=1)
62
+ with pytest.raises(ConnectionError):
63
+ await adapter.connect()
64
+ snap = adapter.health_monitor.snapshot("test")
65
+ assert snap.failure_count >= 1
66
+
67
+
68
+ @pytest.mark.asyncio
69
+ async def test_disconnect_clears_connected():
70
+ adapter = _StubAdapter()
71
+ await adapter.connect()
72
+ await adapter.disconnect()
73
+ assert adapter._connected is False
74
+
75
+
76
+ # ── send ──────────────────────────────────────────────────────────────────────
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_send_returns_msg_id():
80
+ adapter = _StubAdapter()
81
+ await adapter.connect()
82
+ msg_id = await adapter.send("hello", "peer-1")
83
+ assert msg_id == "msg-1"
84
+ assert adapter.send_calls[0] == ("hello", "peer-1")
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_send_failure_records_health():
89
+ class _FailSend(_StubAdapter):
90
+ async def _do_send(self, *a, **kw):
91
+ raise RuntimeError("send failed")
92
+
93
+ adapter = _FailSend()
94
+ await adapter.connect()
95
+ with pytest.raises(RuntimeError):
96
+ await adapter.send("hello", "peer-1")
97
+ snap = adapter.health_monitor.snapshot("test")
98
+ assert snap.failure_count >= 1
99
+
100
+
101
+ # ── health_check ──────────────────────────────────────────────────────────────
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_health_check_true_when_connected():
105
+ adapter = _StubAdapter()
106
+ await adapter.connect()
107
+ assert await adapter.health_check() is True
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_health_check_false_when_disconnected():
112
+ adapter = _StubAdapter()
113
+ # Not connected yet
114
+ assert await adapter.health_check() is False
115
+
116
+
117
+ # ── ConnectionManager ─────────────────────────────────────────────────────────
118
+
119
+ @pytest.mark.asyncio
120
+ async def test_manager_start_all():
121
+ registry = ChannelRegistry()
122
+ registry.register(_StubAdapter("a"))
123
+ registry.register(_StubAdapter("b"))
124
+ manager = ConnectionManager(registry)
125
+ results = await manager.start_all()
126
+ assert results == {"a": True, "b": True}
127
+
128
+
129
+ @pytest.mark.asyncio
130
+ async def test_manager_start_all_partial_failure():
131
+ registry = ChannelRegistry()
132
+ registry.register(_StubAdapter("ok"))
133
+ registry.register(_StubAdapter("fail", fail_connect=True, max_reconnect_attempts=1))
134
+ manager = ConnectionManager(registry)
135
+ results = await manager.start_all()
136
+ assert results["ok"] is True
137
+ assert results["fail"] is False
138
+
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_manager_health_check_all():
142
+ registry = ChannelRegistry()
143
+ a = _StubAdapter("a")
144
+ await a.connect()
145
+ registry.register(a)
146
+ manager = ConnectionManager(registry)
147
+ results = await manager.health_check_all()
148
+ assert results["a"] is True