Three workstreams implemented: W1 (Repo Split): Remove proprietary BloxServer files and docs, update pyproject.toml URLs to public GitHub, clean doc references, add CI workflow (.github/workflows/ci.yml) and CONTRIBUTING.md. W2 (Message Journal): Add DispatchHook protocol for dispatch lifecycle events, SQLite-backed MessageJournal with WAL mode for certified-mail delivery guarantees (PENDING→DISPATCHED→ACKED/FAILED), integrate hooks into StreamPump._dispatch_to_handlers(), add journal REST endpoints, and aiosqlite dependency. W3 (Hot Deployment): Add RestartOrchestrator for graceful restart with queue drain and journal stats collection, SIGHUP signal handler in CLI, POST /organism/restart endpoint, restart-aware app lifespan with journal recovery on boot, and os.execv/subprocess re-exec for Unix/Windows. All 439 tests pass (37 new tests for W2/W3). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
147 lines
4.5 KiB
Python
147 lines
4.5 KiB
Python
"""
|
|
Tests for the DispatchHook protocol and NullDispatchHook.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from xml_pipeline.message_bus.dispatch_hook import DispatchHook, NullDispatchHook
|
|
|
|
|
|
class TestNullDispatchHook:
|
|
"""NullDispatchHook should be a no-op that satisfies the protocol."""
|
|
|
|
async def test_on_intent_returns_empty_string(self):
|
|
hook = NullDispatchHook()
|
|
result = await hook.on_intent(
|
|
thread_id="thread-1",
|
|
from_id="sender",
|
|
to_id="receiver",
|
|
payload_type="Greeting",
|
|
payload_bytes=b"<Greeting><name>Alice</name></Greeting>",
|
|
)
|
|
assert result == ""
|
|
|
|
async def test_on_dispatched_is_noop(self):
|
|
hook = NullDispatchHook()
|
|
await hook.on_dispatched("entry-1") # Should not raise
|
|
|
|
async def test_on_acknowledged_is_noop(self):
|
|
hook = NullDispatchHook()
|
|
await hook.on_acknowledged("entry-1") # Should not raise
|
|
|
|
async def test_on_failed_is_noop(self):
|
|
hook = NullDispatchHook()
|
|
await hook.on_failed("entry-1", "some error") # Should not raise
|
|
|
|
async def test_on_thread_complete_is_noop(self):
|
|
hook = NullDispatchHook()
|
|
await hook.on_thread_complete("thread-1") # Should not raise
|
|
|
|
|
|
class TestDispatchHookProtocol:
|
|
"""DispatchHook protocol should be runtime checkable."""
|
|
|
|
def test_null_hook_satisfies_protocol(self):
|
|
hook = NullDispatchHook()
|
|
assert isinstance(hook, DispatchHook)
|
|
|
|
def test_custom_hook_satisfies_protocol(self):
|
|
"""A custom class implementing all methods should satisfy the protocol."""
|
|
|
|
class MyHook:
|
|
async def on_intent(self, thread_id, from_id, to_id,
|
|
payload_type, payload_bytes):
|
|
return "my-id"
|
|
|
|
async def on_dispatched(self, entry_id):
|
|
pass
|
|
|
|
async def on_acknowledged(self, entry_id):
|
|
pass
|
|
|
|
async def on_failed(self, entry_id, error):
|
|
pass
|
|
|
|
async def on_thread_complete(self, thread_id):
|
|
pass
|
|
|
|
hook = MyHook()
|
|
assert isinstance(hook, DispatchHook)
|
|
|
|
def test_incomplete_class_fails_protocol(self):
|
|
"""A class missing methods should not satisfy the protocol."""
|
|
|
|
class IncompleteHook:
|
|
async def on_intent(self, thread_id, from_id, to_id,
|
|
payload_type, payload_bytes):
|
|
return "id"
|
|
|
|
hook = IncompleteHook()
|
|
assert not isinstance(hook, DispatchHook)
|
|
|
|
|
|
class InMemoryHook:
|
|
"""Test hook that records all calls for assertion."""
|
|
|
|
def __init__(self):
|
|
self.events = []
|
|
|
|
async def on_intent(self, thread_id, from_id, to_id,
|
|
payload_type, payload_bytes):
|
|
entry_id = f"entry-{len(self.events)}"
|
|
self.events.append(("intent", entry_id, thread_id, from_id, to_id))
|
|
return entry_id
|
|
|
|
async def on_dispatched(self, entry_id):
|
|
self.events.append(("dispatched", entry_id))
|
|
|
|
async def on_acknowledged(self, entry_id):
|
|
self.events.append(("acknowledged", entry_id))
|
|
|
|
async def on_failed(self, entry_id, error):
|
|
self.events.append(("failed", entry_id, error))
|
|
|
|
async def on_thread_complete(self, thread_id):
|
|
self.events.append(("thread_complete", thread_id))
|
|
|
|
|
|
class TestInMemoryHook:
|
|
"""Test the in-memory hook used for testing."""
|
|
|
|
async def test_full_lifecycle(self):
|
|
hook = InMemoryHook()
|
|
|
|
# Intent
|
|
eid = await hook.on_intent("t1", "sender", "receiver", "Greeting", b"<xml/>")
|
|
assert eid == "entry-0"
|
|
|
|
# Dispatched
|
|
await hook.on_dispatched(eid)
|
|
|
|
# Acknowledged
|
|
await hook.on_acknowledged(eid)
|
|
|
|
# Thread complete
|
|
await hook.on_thread_complete("t1")
|
|
|
|
assert len(hook.events) == 4
|
|
assert hook.events[0][0] == "intent"
|
|
assert hook.events[1] == ("dispatched", "entry-0")
|
|
assert hook.events[2] == ("acknowledged", "entry-0")
|
|
assert hook.events[3] == ("thread_complete", "t1")
|
|
|
|
async def test_failed_lifecycle(self):
|
|
hook = InMemoryHook()
|
|
|
|
eid = await hook.on_intent("t1", "sender", "receiver", "Greeting", b"<xml/>")
|
|
await hook.on_dispatched(eid)
|
|
await hook.on_failed(eid, "handler crashed")
|
|
|
|
assert len(hook.events) == 3
|
|
assert hook.events[2] == ("failed", "entry-0", "handler crashed")
|
|
|
|
def test_satisfies_protocol(self):
|
|
hook = InMemoryHook()
|
|
assert isinstance(hook, DispatchHook)
|