mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-29 14:10:15 +08:00
348 lines
12 KiB
Python
348 lines
12 KiB
Python
"""公共 HTTP 应用装配与生命周期 canonical 边界合同测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ast
|
|
from types import SimpleNamespace
|
|
from pathlib import Path
|
|
|
|
import api
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
|
|
import backend.application_lifecycle as lifecycle
|
|
from backend.application import create_application
|
|
from backend.application_lifecycle import LifecycleSetup, ScopeRuntime, build_scope_runtime, prepare_lifecycle, shutdown_lifecycle
|
|
from backend.database import DatabaseError
|
|
from backend.scope import LocalScopeResolver, ScopeResolutionError
|
|
|
|
|
|
def _route_snapshot(application) -> list[tuple[str, tuple[str, ...], str, bool]]:
|
|
"""记录路由顺序和公开 schema 标志,避免装配 delegate 改变 HTTP 合同。"""
|
|
return [
|
|
(
|
|
route.path,
|
|
tuple(sorted(route.methods or ())),
|
|
route.name,
|
|
bool(getattr(route, "include_in_schema", False)),
|
|
)
|
|
for route in application.routes
|
|
if hasattr(route, "path") and hasattr(route, "methods")
|
|
]
|
|
|
|
|
|
def test_public_factory_preserves_route_template_and_middleware() -> None:
|
|
"""公共 create_app 与模块级入口保持同一路由和 middleware 顺序。"""
|
|
created = api.create_app(scope_resolver=LocalScopeResolver("local"))
|
|
|
|
assert _route_snapshot(created) == _route_snapshot(api.app)
|
|
assert [middleware.cls for middleware in created.user_middleware] == [middleware.cls for middleware in api.app.user_middleware]
|
|
assert created.state.scope_resolver.scope.scope_id == "local"
|
|
assert create_application.__module__ == "backend.application"
|
|
|
|
|
|
def test_public_factory_rejects_invalid_resolver_before_lifecycle() -> None:
|
|
"""非法 resolver 在应用工厂阶段失败,不能触碰数据库或外部资源。"""
|
|
for value in (None, object(), LocalScopeResolver("other")):
|
|
try:
|
|
api.create_app(scope_resolver=value)
|
|
except ScopeResolutionError:
|
|
continue
|
|
raise AssertionError("非法 resolver 未被拒绝")
|
|
|
|
|
|
def test_lifecycle_canonical_modules_do_not_import_entrypoints() -> None:
|
|
"""canonical 装配和生命周期模块保持单向依赖,避免入口循环导入。"""
|
|
for module in (create_application, prepare_lifecycle):
|
|
tree = ast.parse(Path(module.__code__.co_filename).read_text(encoding="utf-8"))
|
|
imports = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
imports.extend(alias.name for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
imports.append(node.module)
|
|
assert "api" not in imports
|
|
assert "server_api" not in imports
|
|
|
|
|
|
def _lifecycle_settings() -> SimpleNamespace:
|
|
"""返回不触碰文件系统的生命周期测试配置。"""
|
|
return SimpleNamespace(
|
|
opencode_runtime_root=Path("/tmp/mememeow-test-runtime"),
|
|
image_root=Path("/tmp/mememeow-test-images"),
|
|
data_root=Path("/tmp/mememeow-test-data"),
|
|
expected_database_revision="revision",
|
|
opencode_concurrency=1,
|
|
agent_backpressure=1,
|
|
settings_version="test",
|
|
worker_lease_seconds=120,
|
|
worker_max_attempts=3,
|
|
ensure_directories=lambda: None,
|
|
)
|
|
|
|
|
|
def test_prepare_failure_disposes_engine_before_schema_resources_escape() -> None:
|
|
"""schema 门禁失败时,已创建 Engine 必须在原始错误返回前释放。"""
|
|
events: list[str] = []
|
|
|
|
class Engine:
|
|
"""记录 dispose 的 Engine 测试替身。"""
|
|
|
|
def dispose(self) -> None:
|
|
"""记录连接池关闭。"""
|
|
events.append("engine.dispose")
|
|
|
|
app = FastAPI()
|
|
app.state.scope_resolver = LocalScopeResolver("local")
|
|
settings = _lifecycle_settings()
|
|
|
|
def fail_schema(*_args, **_kwargs):
|
|
"""模拟 schema revision 门禁失败。"""
|
|
raise DatabaseError("schema_revision_mismatch")
|
|
|
|
with pytest.raises(DatabaseError, match="schema_revision_mismatch"):
|
|
prepare_lifecycle(
|
|
app,
|
|
settings_loader=lambda: settings,
|
|
engine_factory=lambda _settings: Engine(),
|
|
database_checker=fail_schema,
|
|
)
|
|
|
|
assert events == ["engine.dispose"]
|
|
assert not hasattr(app.state, "database")
|
|
|
|
|
|
def test_prepare_failure_disposes_opencode_and_engine() -> None:
|
|
"""视觉客户端构造失败时,先前取得的 OpenCode 与 Engine 仍全部收束。"""
|
|
events: list[str] = []
|
|
|
|
class Resource:
|
|
"""记录 shutdown 的 OpenCode 测试替身。"""
|
|
|
|
def shutdown(self) -> None:
|
|
"""记录外部运行时关闭。"""
|
|
events.append("opencode.shutdown")
|
|
|
|
class Engine:
|
|
"""记录 dispose 的 Engine 测试替身。"""
|
|
|
|
def dispose(self) -> None:
|
|
"""记录连接池关闭。"""
|
|
events.append("engine.dispose")
|
|
|
|
app = FastAPI()
|
|
app.state.scope_resolver = object()
|
|
settings = _lifecycle_settings()
|
|
runner = Resource()
|
|
|
|
def fail_visual(_settings):
|
|
"""模拟视觉客户端初始化失败。"""
|
|
raise RuntimeError("visual_init_failed")
|
|
|
|
with pytest.raises(RuntimeError, match="visual_init_failed"):
|
|
prepare_lifecycle(
|
|
app,
|
|
settings_loader=lambda: settings,
|
|
engine_factory=lambda _settings: Engine(),
|
|
database_checker=lambda *_args, **_kwargs: None,
|
|
database_resources_factory=lambda *_args, **_kwargs: object(),
|
|
opencode_factory=lambda _settings: runner,
|
|
activity_factory=lambda _root: object(),
|
|
visual_factory=fail_visual,
|
|
)
|
|
|
|
assert events == ["opencode.shutdown", "engine.dispose"]
|
|
|
|
|
|
def test_scope_runtime_failure_closes_factory_worker_and_executor(monkeypatch) -> None:
|
|
"""factory.start_all 失败时,runtime 构造期间取得的后台资源必须全部关闭。"""
|
|
events: list[str] = []
|
|
|
|
class Executor:
|
|
"""记录线程池关闭的测试替身。"""
|
|
|
|
def shutdown(self, *args, **kwargs) -> None:
|
|
"""记录线程池关闭参数。"""
|
|
events.append("executor.shutdown")
|
|
|
|
class WorkerManager:
|
|
"""记录 Worker manager 生命周期的测试替身。"""
|
|
|
|
def __init__(self, *_args, **_kwargs) -> None:
|
|
events.append("worker.init")
|
|
|
|
def shutdown(self) -> None:
|
|
"""记录 manager 关闭。"""
|
|
events.append("worker.shutdown")
|
|
|
|
class Factory:
|
|
"""在启动扫描阶段失败的 scope factory 测试替身。"""
|
|
|
|
def __init__(self, *_args, **_kwargs) -> None:
|
|
events.append("factory.init")
|
|
|
|
def start_all(self):
|
|
"""模拟恢复队列失败。"""
|
|
events.append("factory.start")
|
|
raise RuntimeError("worker_start_failed")
|
|
|
|
def shutdown(self) -> None:
|
|
"""记录 factory 关闭。"""
|
|
events.append("factory.shutdown")
|
|
|
|
class OpenCode:
|
|
"""记录外层 shutdown 仍需收束的 OpenCode 资源。"""
|
|
|
|
def shutdown(self) -> None:
|
|
"""记录 OpenCode 关闭。"""
|
|
events.append("opencode.shutdown")
|
|
|
|
class Engine:
|
|
"""记录外层 shutdown 仍需收束的数据库 Engine。"""
|
|
|
|
def dispose(self) -> None:
|
|
"""记录 Engine 连接池关闭。"""
|
|
events.append("engine.dispose")
|
|
|
|
monkeypatch.setattr(lifecycle, "ThreadPoolExecutor", lambda *args, **kwargs: Executor())
|
|
monkeypatch.setattr(lifecycle, "ScopeServiceFactory", Factory)
|
|
app = FastAPI()
|
|
app.state.database = object()
|
|
app.state.operation_policy_gateway = object()
|
|
app.state.operation_grants = object()
|
|
app.state.opencode = OpenCode()
|
|
settings = _lifecycle_settings()
|
|
setup = LifecycleSetup(
|
|
app=app,
|
|
settings=settings,
|
|
local_mode=False,
|
|
configured_factory=None,
|
|
custom_factory=False,
|
|
configured_agent_input_provider=None,
|
|
engine=Engine(),
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="worker_start_failed"):
|
|
build_scope_runtime(
|
|
setup,
|
|
register_handlers=lambda **_kwargs: None,
|
|
start_services=lambda _services: None,
|
|
task_handlers={},
|
|
worker_manager_factory=WorkerManager,
|
|
)
|
|
|
|
assert events == [
|
|
"worker.init",
|
|
"factory.init",
|
|
"factory.start",
|
|
"factory.shutdown",
|
|
"worker.shutdown",
|
|
"executor.shutdown",
|
|
]
|
|
assert not hasattr(app.state, "service_factory")
|
|
assert hasattr(app.state, "opencode")
|
|
|
|
async def close_prepare_resources() -> None:
|
|
"""模拟 lifespan 外层在 runtime 失败后继续收束 prepare 资源。"""
|
|
await shutdown_lifecycle(setup, None, primary_error=RuntimeError("worker_start_failed"))
|
|
|
|
asyncio.run(close_prepare_resources())
|
|
assert events[-2:] == ["opencode.shutdown", "engine.dispose"]
|
|
|
|
|
|
def test_start_extensions_preserves_partial_startup_for_shutdown() -> None:
|
|
"""扩展启动中途失败时,调用方仍能取得已启动及失败中的扩展。"""
|
|
events: list[str] = []
|
|
|
|
class Extension:
|
|
"""可注入启动失败的扩展替身。"""
|
|
|
|
def __init__(self, name: str, fail: bool = False) -> None:
|
|
self.name = name
|
|
self.fail = fail
|
|
|
|
async def on_startup(self, _app) -> None:
|
|
"""记录启动并按配置失败。"""
|
|
events.append(self.name)
|
|
if self.fail:
|
|
raise RuntimeError("extension_startup_failed")
|
|
|
|
first = Extension("first")
|
|
second = Extension("second", fail=True)
|
|
app = FastAPI()
|
|
app.state.extensions = (first, second)
|
|
started: list[object] = []
|
|
|
|
async def exercise() -> None:
|
|
"""执行一次扩展启动。"""
|
|
with pytest.raises(RuntimeError, match="extension_startup_failed"):
|
|
await lifecycle.start_extensions(app, started)
|
|
|
|
asyncio.run(exercise())
|
|
assert events == ["first", "second"]
|
|
assert started == [first, second]
|
|
|
|
|
|
def test_shutdown_continues_after_extension_error() -> None:
|
|
"""任一扩展关闭失败时,后续扩展、Worker、线程池和 Engine 仍继续执行。"""
|
|
events: list[str] = []
|
|
|
|
class Extension:
|
|
"""记录扩展关闭顺序并可注入失败。"""
|
|
|
|
def __init__(self, name: str, fail: bool = False) -> None:
|
|
self.name = name
|
|
self.fail = fail
|
|
|
|
async def on_shutdown(self, _app) -> None:
|
|
"""记录关闭并按配置失败。"""
|
|
events.append(self.name)
|
|
if self.fail:
|
|
raise RuntimeError("extension_shutdown_failed")
|
|
|
|
class Resource:
|
|
"""记录同步关闭的运行时测试替身。"""
|
|
|
|
def __init__(self, name: str) -> None:
|
|
self.name = name
|
|
|
|
def shutdown(self, *args, **kwargs) -> None:
|
|
"""记录资源关闭。"""
|
|
events.append(self.name)
|
|
|
|
class Engine:
|
|
"""记录 Engine dispose 的测试替身。"""
|
|
|
|
def dispose(self) -> None:
|
|
"""记录连接池关闭。"""
|
|
events.append("engine")
|
|
|
|
app = FastAPI()
|
|
app.state.opencode = Resource("opencode")
|
|
app.state.image_processing_workers = {"local": Resource("image")}
|
|
factory = Resource("factory")
|
|
runtime = ScopeRuntime(factory, Resource("executor"), Resource("worker"), None, None)
|
|
setup = LifecycleSetup(
|
|
app=app,
|
|
settings=_lifecycle_settings(),
|
|
local_mode=False,
|
|
configured_factory=None,
|
|
custom_factory=False,
|
|
configured_agent_input_provider=None,
|
|
engine=Engine(),
|
|
)
|
|
app.state.service_factory = factory
|
|
|
|
async def exercise() -> None:
|
|
"""执行一次关闭并让断言检查顺序。"""
|
|
with pytest.raises(RuntimeError, match="extension_shutdown_failed"):
|
|
await shutdown_lifecycle(
|
|
setup,
|
|
runtime,
|
|
(Extension("first"), Extension("second", fail=True)),
|
|
)
|
|
|
|
asyncio.run(exercise())
|
|
assert events == ["second", "first", "opencode", "image", "factory", "worker", "executor", "engine"]
|