test(gate-b): new-plugin-without-core-changes + dependency blockade proofs
This commit is contained in:
@@ -408,3 +408,171 @@ class TestGateAContractRoundtrip:
|
|||||||
assert get_contract('gate_a_dummy') is None
|
assert get_contract('gate_a_dummy') is None
|
||||||
|
|
||||||
reset_contract_registry_for_testing()
|
reset_contract_registry_for_testing()
|
||||||
|
|
||||||
|
|
||||||
|
# --- Gate B: Block B completion proof (docs/fix-plan-v3.md) ---
|
||||||
|
|
||||||
|
|
||||||
|
def _install_inline_route_module():
|
||||||
|
"""Register a throwaway router module in sys.modules (no file on disk)."""
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
mod_name = 'gate_b_inline_routes'
|
||||||
|
if mod_name in sys.modules:
|
||||||
|
return sys.modules[mod_name]
|
||||||
|
mod = types.ModuleType(mod_name)
|
||||||
|
router = APIRouter(prefix='/api/v1/gate-b-inline', tags=['gate-b-inline'])
|
||||||
|
|
||||||
|
@router.get('/ping')
|
||||||
|
async def ping():
|
||||||
|
return {'pong': 'gate-b'}
|
||||||
|
|
||||||
|
mod.router = router
|
||||||
|
sys.modules[mod_name] = mod
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
class TestGateBNewPluginNoCoreChanges:
|
||||||
|
def test_inline_plugin_route_mounted_and_entity_registered(self):
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI
|
||||||
|
from sqlalchemy import Column, Integer, String, Uuid
|
||||||
|
|
||||||
|
from app.core.db import Base
|
||||||
|
from app.deps import require_active_plugin
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.manifest import PluginManifest, PluginRouteDef
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
from app.services.entity_permission_service import (
|
||||||
|
ENTITY_MODELS,
|
||||||
|
register_entity_model,
|
||||||
|
unregister_entity_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
_install_inline_route_module()
|
||||||
|
|
||||||
|
# Classic Column style: Mapped[] annotations cannot be resolved for
|
||||||
|
# classes defined inside a function (no module-level globals).
|
||||||
|
class GateBInlineThing(Base):
|
||||||
|
__tablename__ = 'gate_b_inline_things'
|
||||||
|
id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
tenant_id = Column(Uuid(as_uuid=True), nullable=False)
|
||||||
|
name = Column(String(100))
|
||||||
|
sort_order = Column(Integer)
|
||||||
|
|
||||||
|
class GateBInlinePlugin(BasePlugin):
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name='gate_b_inline',
|
||||||
|
version='1.0.0',
|
||||||
|
display_name='Gate B Inline',
|
||||||
|
description='Proves new-plugin capability without core changes.',
|
||||||
|
dependencies=[],
|
||||||
|
routes=[
|
||||||
|
PluginRouteDef(
|
||||||
|
path='/api/v1/gate-b-inline',
|
||||||
|
module='gate_b_inline_routes',
|
||||||
|
router_attr='router',
|
||||||
|
)
|
||||||
|
],
|
||||||
|
events=[],
|
||||||
|
migrations=[],
|
||||||
|
permissions=['gate_b_inline:read'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_entity_models(self):
|
||||||
|
return {'gate_b_thing': GateBInlineThing}
|
||||||
|
|
||||||
|
plugin = GateBInlinePlugin()
|
||||||
|
registry = get_registry()
|
||||||
|
registry._plugins['gate_b_inline'] = plugin
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1) Route gets mounted by the plugin-route mechanism
|
||||||
|
app = FastAPI()
|
||||||
|
for route_def in plugin.manifest.routes:
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
router_module = importlib.import_module(route_def.module)
|
||||||
|
router = getattr(router_module, route_def.router_attr)
|
||||||
|
app.include_router(
|
||||||
|
router, dependencies=[Depends(require_active_plugin('gate_b_inline'))]
|
||||||
|
)
|
||||||
|
paths = app.openapi()['paths']
|
||||||
|
assert '/api/v1/gate-b-inline/ping' in paths
|
||||||
|
|
||||||
|
# 2) Entity registers through the standard activation path
|
||||||
|
register_entity_model('gate_b_thing', GateBInlineThing)
|
||||||
|
try:
|
||||||
|
assert ENTITY_MODELS['gate_b_thing'] is GateBInlineThing
|
||||||
|
finally:
|
||||||
|
unregister_entity_model('gate_b_thing')
|
||||||
|
finally:
|
||||||
|
registry._plugins.pop('gate_b_inline', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestGateBDependencyBlockade:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deactivate_blocked_while_dependent_active(self, db_session):
|
||||||
|
from app.models.plugin import Plugin as PluginModel
|
||||||
|
from app.plugins.base import BasePlugin
|
||||||
|
from app.plugins.manifest import PluginManifest
|
||||||
|
from app.plugins.registry import get_registry
|
||||||
|
|
||||||
|
class DepB(BasePlugin):
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name='gate_b_dep_b',
|
||||||
|
version='1.0.0',
|
||||||
|
display_name='Dep B',
|
||||||
|
dependencies=[],
|
||||||
|
routes=[],
|
||||||
|
events=[],
|
||||||
|
migrations=[],
|
||||||
|
permissions=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
class DepA(BasePlugin):
|
||||||
|
manifest = PluginManifest(
|
||||||
|
name='gate_b_dep_a',
|
||||||
|
version='1.0.0',
|
||||||
|
display_name='Dep A',
|
||||||
|
dependencies=['gate_b_dep_b'],
|
||||||
|
routes=[],
|
||||||
|
events=[],
|
||||||
|
migrations=[],
|
||||||
|
permissions=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
registry = get_registry()
|
||||||
|
registry._plugins['gate_b_dep_b'] = DepB()
|
||||||
|
registry._plugins['gate_b_dep_a'] = DepA()
|
||||||
|
|
||||||
|
records = {}
|
||||||
|
try:
|
||||||
|
for name in ('gate_b_dep_b', 'gate_b_dep_a'):
|
||||||
|
rec = PluginModel(
|
||||||
|
name=name,
|
||||||
|
display_name=name,
|
||||||
|
version='1.0.0',
|
||||||
|
status='active',
|
||||||
|
installed=True,
|
||||||
|
active=True,
|
||||||
|
is_core=False,
|
||||||
|
)
|
||||||
|
db_session.add(rec)
|
||||||
|
records[name] = rec
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
# Deactivating the dependency must be blocked because gate_b_dep_a
|
||||||
|
# is active and depends on it.
|
||||||
|
with pytest.raises(ValueError, match='depend'):
|
||||||
|
await registry.deactivate(db_session, 'gate_b_dep_b')
|
||||||
|
finally:
|
||||||
|
for rec in records.values():
|
||||||
|
await db_session.delete(rec)
|
||||||
|
await db_session.commit()
|
||||||
|
registry._plugins.pop('gate_b_dep_b', None)
|
||||||
|
registry._plugins.pop('gate_b_dep_a', None)
|
||||||
|
|||||||
Reference in New Issue
Block a user