test: add comprehensive test suite for Phase 2A event bridge
This commit is contained in:
10
tests/__init__.py
Normal file
10
tests/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
EVENTS_DIR = REPO_ROOT / "tools" / "orchestrator-matrix" / "events"
|
||||
if str(EVENTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(EVENTS_DIR))
|
||||
121
tests/conftest.py
Normal file
121
tests/conftest.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
EVENTS_DIR = REPO_ROOT / "tools" / "orchestrator-matrix" / "events"
|
||||
if str(EVENTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(EVENTS_DIR))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TempPaths:
|
||||
root: pathlib.Path
|
||||
events_path: pathlib.Path
|
||||
cursor_path: pathlib.Path
|
||||
config_path: pathlib.Path
|
||||
|
||||
|
||||
def make_temp_paths() -> tuple[tempfile.TemporaryDirectory[str], TempPaths]:
|
||||
tempdir = tempfile.TemporaryDirectory()
|
||||
root = pathlib.Path(tempdir.name)
|
||||
orch_dir = root / ".mosaic" / "orchestrator"
|
||||
return tempdir, TempPaths(
|
||||
root=root,
|
||||
events_path=orch_dir / "events.ndjson",
|
||||
cursor_path=orch_dir / "event_cursor.json",
|
||||
config_path=orch_dir / "config.json",
|
||||
)
|
||||
|
||||
|
||||
def sample_events() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"event_type": "task.assigned",
|
||||
"task_id": "TASK-001",
|
||||
"message": "Assigned to codex",
|
||||
"metadata": {"runtime": "codex", "dispatch": "exec"},
|
||||
},
|
||||
{
|
||||
"event_type": "task.started",
|
||||
"task_id": "TASK-001",
|
||||
"message": "Worker execution started",
|
||||
"metadata": {"runtime": "codex", "dispatch": "exec", "attempt": 1, "max_attempts": 3},
|
||||
},
|
||||
{
|
||||
"event_type": "task.completed",
|
||||
"task_id": "TASK-001",
|
||||
"message": "Completed successfully",
|
||||
"metadata": {"task_title": "Finish test suite", "duration_seconds": 12.4},
|
||||
},
|
||||
{
|
||||
"event_type": "task.failed",
|
||||
"task_id": "TASK-002",
|
||||
"message": "Exploded with stack trace",
|
||||
"metadata": {"attempt": 2, "max_attempts": 3},
|
||||
},
|
||||
{
|
||||
"event_type": "task.escalated",
|
||||
"task_id": "TASK-003",
|
||||
"message": "Human review required",
|
||||
"metadata": {"attempt": 1},
|
||||
},
|
||||
{
|
||||
"event_type": "task.gated",
|
||||
"task_id": "TASK-004",
|
||||
"message": "Waiting for quality gates",
|
||||
"metadata": {},
|
||||
},
|
||||
{
|
||||
"event_type": "task.retry.scheduled",
|
||||
"task_id": "TASK-002",
|
||||
"message": "Retry scheduled",
|
||||
"metadata": {"attempt": 3, "max_attempts": 3},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def sample_config(
|
||||
*,
|
||||
enabled: bool = True,
|
||||
event_filter: list[str] | None = None,
|
||||
url: str = "https://hooks.example.com/macp",
|
||||
retry_count: int = 1,
|
||||
timeout_seconds: float = 2.0,
|
||||
auth_token: str = "token-123",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"macp": {
|
||||
"watch_poll_interval_seconds": 0.1,
|
||||
"webhook": {
|
||||
"enabled": enabled,
|
||||
"url": url,
|
||||
"event_filter": list(event_filter or []),
|
||||
"retry_count": retry_count,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
"auth_token": auth_token,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def write_ndjson(path: pathlib.Path, events: list[dict[str, Any]], extra_lines: list[str] | None = None) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [json.dumps(event) for event in events]
|
||||
if extra_lines:
|
||||
lines.extend(extra_lines)
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def append_ndjson(path: pathlib.Path, events: list[dict[str, Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as handle:
|
||||
for event in events:
|
||||
handle.write(json.dumps(event))
|
||||
handle.write("\n")
|
||||
4
tests/run_tests.sh
Executable file
4
tests/run_tests.sh
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
python3 -m unittest discover -s tests -p 'test_*.py' -v
|
||||
86
tests/test_discord_formatter.py
Normal file
86
tests/test_discord_formatter.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tests.conftest import sample_events
|
||||
from discord_formatter import format_event, format_summary
|
||||
|
||||
|
||||
class DiscordFormatterTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.events = sample_events()
|
||||
|
||||
def test_format_completed(self) -> None:
|
||||
formatted = format_event(self.events[2])
|
||||
|
||||
self.assertIsNotNone(formatted)
|
||||
self.assertIn("✅", formatted)
|
||||
self.assertIn("TASK-001", formatted)
|
||||
|
||||
def test_format_failed(self) -> None:
|
||||
formatted = format_event(self.events[3])
|
||||
|
||||
self.assertIsNotNone(formatted)
|
||||
self.assertIn("❌", formatted)
|
||||
self.assertIn("Exploded with stack trace", formatted)
|
||||
|
||||
def test_format_escalated(self) -> None:
|
||||
formatted = format_event(self.events[4])
|
||||
|
||||
self.assertIsNotNone(formatted)
|
||||
self.assertIn("🚨", formatted)
|
||||
self.assertIn("Human review required", formatted)
|
||||
|
||||
def test_format_gated(self) -> None:
|
||||
formatted = format_event(self.events[5])
|
||||
|
||||
self.assertIsNotNone(formatted)
|
||||
self.assertIn("🔍", formatted)
|
||||
|
||||
def test_format_started(self) -> None:
|
||||
formatted = format_event(self.events[1])
|
||||
|
||||
self.assertIsNotNone(formatted)
|
||||
self.assertIn("⚙️", formatted)
|
||||
self.assertIn("Worker: codex", formatted)
|
||||
|
||||
def test_format_unknown_type(self) -> None:
|
||||
self.assertIsNone(format_event({"event_type": "task.unknown", "task_id": "TASK-999"}))
|
||||
|
||||
def test_sanitize_control_chars(self) -> None:
|
||||
event = {
|
||||
"event_type": "task.failed",
|
||||
"task_id": "TASK-200",
|
||||
"message": "bad\x00news\x1b[31m!",
|
||||
}
|
||||
|
||||
formatted = format_event(event)
|
||||
|
||||
self.assertIsNotNone(formatted)
|
||||
self.assertNotIn("\x00", formatted)
|
||||
self.assertNotIn("\x1b", formatted)
|
||||
self.assertIn("bad news !", formatted)
|
||||
|
||||
def test_sanitize_mentions(self) -> None:
|
||||
event = {
|
||||
"event_type": "task.escalated",
|
||||
"task_id": "TASK-201",
|
||||
"message": "@everyone investigate",
|
||||
}
|
||||
|
||||
formatted = format_event(event)
|
||||
|
||||
self.assertIsNotNone(formatted)
|
||||
self.assertIn("@\u200beveryone investigate", formatted)
|
||||
|
||||
def test_format_summary(self) -> None:
|
||||
summary = format_summary([self.events[1], self.events[2], self.events[3]])
|
||||
|
||||
self.assertIn("3 events", summary)
|
||||
self.assertIn("task.completed: 1", summary)
|
||||
self.assertIn("task.failed: 1", summary)
|
||||
self.assertIn("task.started: 1", summary)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
99
tests/test_event_watcher.py
Normal file
99
tests/test_event_watcher.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from tests.conftest import append_ndjson, make_temp_paths, sample_events, write_ndjson
|
||||
from event_watcher import EventWatcher
|
||||
|
||||
|
||||
class EventWatcherTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tempdir, self.paths = make_temp_paths()
|
||||
self.events = sample_events()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def watcher(self) -> EventWatcher:
|
||||
return EventWatcher(self.paths.events_path, self.paths.cursor_path, poll_interval=0.1)
|
||||
|
||||
def test_poll_empty_file(self) -> None:
|
||||
watcher = self.watcher()
|
||||
self.assertEqual(watcher.poll_once(), [])
|
||||
self.assertFalse(self.paths.cursor_path.exists())
|
||||
|
||||
def test_poll_new_events(self) -> None:
|
||||
write_ndjson(self.paths.events_path, self.events[:3])
|
||||
|
||||
polled = self.watcher().poll_once()
|
||||
|
||||
self.assertEqual(polled, self.events[:3])
|
||||
|
||||
def test_cursor_persistence(self) -> None:
|
||||
watcher = self.watcher()
|
||||
write_ndjson(self.paths.events_path, self.events[:3])
|
||||
|
||||
first = watcher.poll_once()
|
||||
second = watcher.poll_once()
|
||||
|
||||
self.assertEqual(first, self.events[:3])
|
||||
self.assertEqual(second, [])
|
||||
cursor = json.loads(self.paths.cursor_path.read_text(encoding="utf-8"))
|
||||
self.assertGreater(cursor["position"], 0)
|
||||
|
||||
def test_cursor_survives_restart(self) -> None:
|
||||
write_ndjson(self.paths.events_path, self.events[:3])
|
||||
|
||||
first_watcher = self.watcher()
|
||||
self.assertEqual(first_watcher.poll_once(), self.events[:3])
|
||||
|
||||
second_watcher = self.watcher()
|
||||
self.assertEqual(second_watcher.poll_once(), [])
|
||||
|
||||
def test_corrupt_line_skipped(self) -> None:
|
||||
self.paths.events_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self.paths.events_path.open("w", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(self.events[0]) + "\n")
|
||||
handle.write("{not-json}\n")
|
||||
handle.write(json.dumps(self.events[1]) + "\n")
|
||||
|
||||
polled = self.watcher().poll_once()
|
||||
|
||||
self.assertEqual(polled, [self.events[0], self.events[1]])
|
||||
|
||||
def test_callback_filtering(self) -> None:
|
||||
write_ndjson(self.paths.events_path, self.events)
|
||||
received: list[dict[str, object]] = []
|
||||
watcher = self.watcher()
|
||||
watcher.on(["task.completed"], received.append)
|
||||
|
||||
watcher.poll_once()
|
||||
|
||||
self.assertEqual(received, [self.events[2]])
|
||||
|
||||
def test_callback_receives_events(self) -> None:
|
||||
write_ndjson(self.paths.events_path, self.events[:2])
|
||||
received: list[dict[str, object]] = []
|
||||
watcher = self.watcher()
|
||||
watcher.on([], received.append)
|
||||
|
||||
polled = watcher.poll_once()
|
||||
|
||||
self.assertEqual(received, self.events[:2])
|
||||
self.assertEqual(polled, self.events[:2])
|
||||
|
||||
def test_file_grows_between_polls(self) -> None:
|
||||
watcher = self.watcher()
|
||||
write_ndjson(self.paths.events_path, self.events[:2])
|
||||
|
||||
first = watcher.poll_once()
|
||||
append_ndjson(self.paths.events_path, self.events[2:5])
|
||||
second = watcher.poll_once()
|
||||
|
||||
self.assertEqual(first, self.events[:2])
|
||||
self.assertEqual(second, self.events[2:5])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
100
tests/test_webhook_adapter.py
Normal file
100
tests/test_webhook_adapter.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import urllib.error
|
||||
from unittest.mock import patch
|
||||
|
||||
from tests.conftest import sample_config, sample_events
|
||||
from webhook_adapter import create_webhook_callback, send_webhook
|
||||
|
||||
|
||||
class MockHTTPResponse:
|
||||
def __init__(self, status: int) -> None:
|
||||
self.status = status
|
||||
|
||||
def getcode(self) -> int:
|
||||
return self.status
|
||||
|
||||
def __enter__(self) -> "MockHTTPResponse":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class WebhookAdapterTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.events = sample_events()
|
||||
self.completed_event = self.events[2]
|
||||
self.started_event = self.events[1]
|
||||
|
||||
@patch("webhook_adapter.urllib.request.urlopen", return_value=MockHTTPResponse(200))
|
||||
def test_send_webhook_success(self, mock_urlopen) -> None:
|
||||
config = sample_config()
|
||||
|
||||
result = send_webhook(self.completed_event, config)
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(mock_urlopen.call_count, 1)
|
||||
request = mock_urlopen.call_args.args[0]
|
||||
self.assertEqual(request.full_url, "https://hooks.example.com/macp")
|
||||
self.assertEqual(request.get_method(), "POST")
|
||||
self.assertEqual(request.headers["Authorization"], "Bearer token-123")
|
||||
|
||||
@patch("webhook_adapter.urllib.request.urlopen", return_value=MockHTTPResponse(500))
|
||||
def test_send_webhook_failure(self, mock_urlopen) -> None:
|
||||
result = send_webhook(self.completed_event, sample_config(retry_count=0))
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(mock_urlopen.call_count, 1)
|
||||
|
||||
@patch("webhook_adapter.urllib.request.urlopen", side_effect=TimeoutError("timed out"))
|
||||
def test_send_webhook_timeout(self, mock_urlopen) -> None:
|
||||
result = send_webhook(self.completed_event, sample_config(retry_count=0))
|
||||
|
||||
self.assertFalse(result)
|
||||
self.assertEqual(mock_urlopen.call_count, 1)
|
||||
|
||||
@patch("webhook_adapter.time.sleep", return_value=None)
|
||||
@patch(
|
||||
"webhook_adapter.urllib.request.urlopen",
|
||||
side_effect=[MockHTTPResponse(500), MockHTTPResponse(200)],
|
||||
)
|
||||
def test_send_webhook_retry(self, mock_urlopen, mock_sleep) -> None:
|
||||
result = send_webhook(self.completed_event, sample_config(retry_count=1))
|
||||
|
||||
self.assertTrue(result)
|
||||
self.assertEqual(mock_urlopen.call_count, 2)
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
@patch("webhook_adapter.send_webhook", return_value=True)
|
||||
def test_event_filter(self, mock_send_webhook) -> None:
|
||||
callback = create_webhook_callback(sample_config(event_filter=["task.completed"]))
|
||||
|
||||
callback(self.started_event)
|
||||
callback(self.completed_event)
|
||||
|
||||
mock_send_webhook.assert_called_once_with(self.completed_event, sample_config(event_filter=["task.completed"]))
|
||||
|
||||
@patch("webhook_adapter.send_webhook")
|
||||
def test_webhook_disabled(self, mock_send_webhook) -> None:
|
||||
callback = create_webhook_callback(sample_config(enabled=False))
|
||||
|
||||
callback(self.completed_event)
|
||||
|
||||
mock_send_webhook.assert_not_called()
|
||||
|
||||
@patch("webhook_adapter.urllib.request.urlopen")
|
||||
def test_ssrf_blocked(self, mock_urlopen) -> None:
|
||||
for url in ("http://127.0.0.1/webhook", "http://10.1.2.3/webhook"):
|
||||
with self.subTest(url=url):
|
||||
result = send_webhook(
|
||||
self.completed_event,
|
||||
sample_config(url=url, auth_token="", retry_count=0),
|
||||
)
|
||||
self.assertFalse(result)
|
||||
mock_urlopen.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user