xml-pipeline/tests/test_dispatch_hook.py
dullfig d97c24b1dd
Some checks failed
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / typecheck (push) Has been cancelled
Add message journal, graceful restart, and clean repo for public release
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>
2026-01-28 22:27:38 -08:00

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)