Files

158 lines
5.5 KiB
Python

from __future__ import annotations
import types
import pytest
from fastapi import APIRouter, FastAPI
from fastapi.testclient import TestClient
from app.extensions.contracts import (
BACKEND_EXTENSION_API_VERSION,
NotificationFormatContext,
NotificationFormatter,
)
from app.extensions.loader import configure_backend_extensions
from app.extensions.registry import BackendExtensionRegistrar, BackendExtensionRegistry
from app.services.quote_service import QuoteService
class PrefixFormatter(NotificationFormatter):
def __init__(self, prefix: str) -> None:
self.prefix = prefix
def format_message(self, event: dict, context: NotificationFormatContext) -> str:
assert context.api_version == BACKEND_EXTENSION_API_VERSION
return f"{self.prefix}{event['message']}"
class BrokenFormatter(NotificationFormatter):
def format_message(self, event: dict, context: NotificationFormatContext) -> str:
del event, context
raise RuntimeError("broken formatter")
def _registrar(extension_id: str = "company.test") -> BackendExtensionRegistrar:
return BackendExtensionRegistrar(
extension_id,
api_version=BACKEND_EXTENSION_API_VERSION,
)
def test_empty_registry_preserves_existing_notification_objects() -> None:
registry = BackendExtensionRegistry()
registry.freeze()
service = QuoteService()
service._app_state = types.SimpleNamespace(extension_registry=registry)
events = [{"message": "原始消息", "source": "strategy"}]
result = service._format_extension_notifications(events)
assert result is events
assert result[0] is events[0]
def test_notification_formatters_are_ordered_and_do_not_mutate_input() -> None:
registry = BackendExtensionRegistry()
registrar = _registrar()
registrar.register_notification_formatter("company.second", PrefixFormatter("B"), order=20)
registrar.register_notification_formatter("company.first", PrefixFormatter("A"), order=10)
registry.register(registrar)
registry.freeze()
service = QuoteService()
service._app_state = types.SimpleNamespace(extension_registry=registry)
events = [{"message": "原始消息", "source": "strategy"}]
result = service._format_extension_notifications(events)
assert result == [{"message": "BA原始消息", "source": "strategy"}]
assert events == [{"message": "原始消息", "source": "strategy"}]
assert result is not events
assert result[0] is not events[0]
def test_broken_formatter_keeps_previous_message_and_later_formatters_run() -> None:
registry = BackendExtensionRegistry()
registrar = _registrar()
registrar.register_notification_formatter("company.first", PrefixFormatter("A"), order=10)
registrar.register_notification_formatter("company.broken", BrokenFormatter(), order=20)
registrar.register_notification_formatter("company.last", PrefixFormatter("B"), order=30)
registry.register(registrar)
registry.freeze()
service = QuoteService()
service._app_state = types.SimpleNamespace(extension_registry=registry)
result = service._format_extension_notifications([{"message": "原始消息"}])
assert result[0]["message"] == "BA原始消息"
def test_registry_rejects_version_mismatch_without_partial_registration() -> None:
registry = BackendExtensionRegistry()
registrar = BackendExtensionRegistrar("company.future", api_version=999)
registrar.register_notification_formatter("company.future", PrefixFormatter("x"))
with pytest.raises(ValueError, match="requires backend API"):
registry.register(registrar)
registry.freeze()
assert not registry.has_customizations
assert not registry.has_notification_formatters
def test_registry_is_frozen_after_startup() -> None:
registry = BackendExtensionRegistry()
registry.freeze()
with pytest.raises(RuntimeError, match="frozen"):
registry.register(_registrar())
def test_loader_isolates_failed_setup_and_registers_valid_route(
monkeypatch: pytest.MonkeyPatch,
) -> None:
broken = types.ModuleType("app.custom.broken")
broken.EXTENSION_ID = "company.broken"
broken.EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION
def broken_setup(registrar: BackendExtensionRegistrar) -> None:
registrar.register_notification_formatter("company.partial", PrefixFormatter("x"))
raise RuntimeError("setup failed")
broken.setup = broken_setup
valid = types.ModuleType("app.custom.valid")
valid.EXTENSION_ID = "company.valid"
valid.EXTENSION_API_VERSION = BACKEND_EXTENSION_API_VERSION
def valid_setup(registrar: BackendExtensionRegistrar) -> None:
router = APIRouter(prefix="/api/custom/valid")
@router.get("/status")
def status() -> dict:
return {"status": "ok"}
registrar.include_router(router)
valid.setup = valid_setup
modules = {broken.__name__: broken, valid.__name__: valid}
monkeypatch.setattr(
"app.extensions.loader._custom_module_names",
lambda: [broken.__name__, valid.__name__],
)
monkeypatch.setattr(
"app.extensions.loader.importlib.import_module",
lambda name: modules[name],
)
app = FastAPI()
registry, errors = configure_backend_extensions(app)
assert registry.frozen
assert registry.extension_ids() == frozenset({"company.valid"})
assert len(errors) == 1
assert errors[0].module == broken.__name__
client = TestClient(app)
response = client.get("/api/custom/valid/status")
assert response.status_code == 200