diff --git a/tests/unit/test_plugin_registry.py b/tests/unit/test_plugin_registry.py new file mode 100644 index 0000000..010589e --- /dev/null +++ b/tests/unit/test_plugin_registry.py @@ -0,0 +1,139 @@ +import pytest + +from proxy_pool.plugins.protocols import CheckContext, CheckResult, DiscoveredProxy, Event +from proxy_pool.plugins.registry import PluginConflictError, PluginNotFoundError, PluginRegistry, PluginValidationError + + +class FakeParser: + name = "fake" + + def supports(self, url: str) -> bool: + return "fake" in url + + async def parse(self, raw, source_url, source_id, default_protocol): + return [DiscoveredProxy(ip="1.2.3.4", port=8080, protocol="http")] + + def default_schedule(self): + return None + + +class FakeChecker: + name = "fake_check" + stage = 1 + priority = 0 + timeout = 5.0 + + async def check(self, proxy_ip, proxy_port, proxy_protocol, context): + return CheckResult(passed=True, detail="ok") + + def should_skip(self, proxy_protocol): + return False + + +class FakeNotifier: + name = "fake_notify" + subscribes_to = ["test.*"] + notified_events: list[Event] + + def __init__(self): + self.notified_events = [] + + async def notify(self, event): + self.notified_events.append(event) + + async def health_check(self): + return True + + +class TestParserRegistration: + def test_register_and_retrieve(self): + registry = PluginRegistry() + parser = FakeParser() + registry.register_parser(parser) + + assert registry.get_parser("fake") is parser + + def test_duplicate_name_raises_conflict(self): + registry = PluginRegistry() + registry.register_parser(FakeParser()) + + with pytest.raises(PluginConflictError): + registry.register_parser(FakeParser()) + + def test_get_missing_parser_raises(self): + registry = PluginRegistry() + + with pytest.raises(PluginNotFoundError): + registry.get_parser("nonexistent") + + def test_get_parser_for_url(self): + registry = PluginRegistry() + parser = FakeParser() + registry.register_parser(parser) + + assert registry.get_parser_for_url("http://fake.com/list") is parser + assert registry.get_parser_for_url("http://other.com/list") is None + + def test_invalid_plugin_raises_validation_error(self): + registry = PluginRegistry() + + with pytest.raises(PluginValidationError): + registry.register_parser("not a plugin") + + +class TestCheckerRegistration: + def test_checkers_sorted_by_stage_and_priority(self): + registry = PluginRegistry() + + class Late(FakeChecker): + name = "late" + stage = 2 + priority = 0 + + class Early(FakeChecker): + name = "early" + stage = 1 + priority = 0 + + class EarlyLow(FakeChecker): + name = "early_low" + stage = 1 + priority = 10 + + registry.register_checker(Late()) + registry.register_checker(EarlyLow()) + registry.register_checker(Early()) + + pipeline = registry.get_checker_pipeline() + names = [c.name for c in pipeline] + assert names == ["early", "early_low", "late"] + + +class TestEventBus: + async def test_emit_notifies_matching_subscribers(self): + registry = PluginRegistry() + notifier = FakeNotifier() + registry.register_notifier(notifier) + + event = Event(type="test.something", payload={"key": "value"}) + await registry.emit(event) + + # Give the fire-and-forget task a moment to run + import asyncio + await asyncio.sleep(0.05) + + assert len(notifier.notified_events) == 1 + assert notifier.notified_events[0].type == "test.something" + + async def test_emit_skips_non_matching(self): + registry = PluginRegistry() + notifier = FakeNotifier() + notifier.subscribes_to = ["other.*"] + registry.register_notifier(notifier) + + await registry.emit(Event(type="test.something", payload={})) + + import asyncio + await asyncio.sleep(0.05) + + assert len(notifier.notified_events) == 0 \ No newline at end of file