"""Plugin discovery + core wiring (SPEC ยง11).""" from __future__ import annotations import httpx import pytest from fastapi import APIRouter, FastAPI from httpx import ASGITransport from app.events import get_event_bus from app.plugins import loader from app.plugins.sdk import Page, PageNav, Plugin, Slot, UiManifest class _FakeEP: def __init__(self, name: str, obj: object) -> None: self.name = name self._obj = obj def load(self) -> object: return self._obj def test_discover_reads_entry_points(monkeypatch: pytest.MonkeyPatch) -> None: plugin = Plugin(name="filters", version="1.2") monkeypatch.setattr(loader, "entry_points", lambda group: [_FakeEP("filters", plugin)]) loader.set_plugins_for_test(None) # force rediscovery through the fake found = loader.discover_plugins() assert [(p.name, p.version) for p in found] == [("filters", "1.2")] def test_discover_accepts_module_exposing_plugin(monkeypatch: pytest.MonkeyPatch) -> None: class _Mod: plugin = Plugin(name="labels", version="0.1") monkeypatch.setattr(loader, "entry_points", lambda group: [_FakeEP("labels", _Mod())]) loader.set_plugins_for_test(None) assert [p.name for p in loader.discover_plugins()] == ["labels"] def test_discover_skips_duplicate_names(monkeypatch: pytest.MonkeyPatch) -> None: eps = [ _FakeEP("a", Plugin(name="dup", version="1")), _FakeEP("b", Plugin(name="dup", version="2")), ] monkeypatch.setattr(loader, "entry_points", lambda group: eps) loader.set_plugins_for_test(None) found = loader.discover_plugins() assert [p.version for p in found] == ["1"] # first wins, duplicate dropped async def test_register_hooks_forwards_events() -> None: seen: list[dict[str, object]] = [] async def on_new(payload: dict[str, object]) -> None: seen.append(payload) loader.set_plugins_for_test( [Plugin(name="watcher", version="1", hooks={"message.new": on_new})] ) bus = get_event_bus() loader.register_hooks(bus) loader.register_hooks(bus) # idempotent: no double subscription await bus.publish("message.new", {"user_id": "u", "message_id": "m"}) assert seen == [{"user_id": "u", "message_id": "m"}] async def test_mount_routers_prefixes_by_name() -> None: r = APIRouter() @r.get("/ping") async def _ping() -> dict[str, str]: return {"ok": "1"} loader.set_plugins_for_test([Plugin(name="demo", version="1", router=r)]) app = FastAPI() loader.mount_routers(app) transport = ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: resp = await client.get("/api/plugins/demo/ping") assert resp.status_code == 200 assert resp.json() == {"ok": "1"} def test_build_ui_manifest_aggregates() -> None: manifest = UiManifest( slots=[Slot(name="message.actions", bundle="/a.js")], pages=[ Page( route="/notes", nav=PageNav(icon="note", label="Notes", order=5), bundle="/p.js", ) ], capabilities={"recipients.suggest": "/api/plugins/contacts/suggest"}, ) loader.set_plugins_for_test([Plugin(name="notes", version="2.0", ui_manifest=manifest)]) out = loader.build_ui_manifest() assert out == { "plugins": [ { "name": "notes", "version": "2.0", "builtin": False, "slots": [{"name": "message.actions", "bundle": "/a.js"}], "pages": [ { "route": "/notes", "bundle": "/p.js", "nav": {"icon": "note", "label": "Notes", "order": 5}, } ], "capabilities": {"recipients.suggest": "/api/plugins/contacts/suggest"}, } ] }