mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-17 04:41:01 +08:00
981 lines
40 KiB
Python
981 lines
40 KiB
Python
"""应用 scope 解析边界和显式 FastAPI 装配测试。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
|
|
import api as api_module
|
|
from api import bind_request_scope, create_app
|
|
from backend.database import ScopeContext
|
|
from backend.reverse_image import ReverseImageProviderBinding
|
|
from backend.scope import (
|
|
LocalScopeResolver,
|
|
ScopeResolutionError,
|
|
ScopeServiceFactory,
|
|
ScopeServices,
|
|
ScopeServicesError,
|
|
resolve_scope,
|
|
validate_scope_services,
|
|
)
|
|
from backend.tasks import TaskRecord
|
|
|
|
|
|
def _request(resolver: object | None = None):
|
|
"""构造不依赖 ASGI 生命周期的最小 resolver 请求。"""
|
|
state = SimpleNamespace()
|
|
if resolver is not None:
|
|
state.scope_resolver = resolver
|
|
return SimpleNamespace(app=SimpleNamespace(state=state))
|
|
|
|
|
|
class _ClosedScopeResolver:
|
|
"""模拟适配宿主注入的非 local 可信 scope resolver。"""
|
|
|
|
def resolve(self, request):
|
|
"""返回宿主认证后绑定的 scope,不读取客户端请求字段。"""
|
|
del request
|
|
return "scope-a"
|
|
|
|
|
|
def _empty_reverse_image_search(_request) -> dict[str, object]:
|
|
"""返回合法空结果,供公共 provider binding 装配测试使用。"""
|
|
return {"visual_matches": []}
|
|
|
|
|
|
def _install_lifespan_doubles(monkeypatch, tmp_path: Path) -> SimpleNamespace:
|
|
"""为生命周期测试注入无数据库副作用的资源、Worker 和 scope facade 替身。"""
|
|
|
|
class FakeEngine:
|
|
"""记录共享 Engine 是否在生命周期结束时释放。"""
|
|
|
|
def __init__(self) -> None:
|
|
self.disposed = False
|
|
|
|
def dispose(self) -> None:
|
|
"""记录 dispose 调用。"""
|
|
self.disposed = True
|
|
|
|
class FakeSettings:
|
|
"""提供默认 factory 装配所需的最小只读配置。"""
|
|
|
|
image_root = tmp_path / "images"
|
|
data_root = tmp_path / "data"
|
|
opencode_runtime_root = tmp_path / "runtime"
|
|
database_url = "postgresql+psycopg://host/test"
|
|
expected_database_revision = "test-revision"
|
|
opencode_concurrency = 1
|
|
agent_backpressure = 32
|
|
settings_version = "test-settings"
|
|
worker_lease_seconds = 120
|
|
worker_max_attempts = 3
|
|
|
|
def ensure_directories(self) -> None:
|
|
"""创建测试运行目录,不触碰数据库 scope。"""
|
|
self.data_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
class FakeResources:
|
|
"""记录 local scope 开关并拒绝任何未预期的 preflight。"""
|
|
|
|
def __init__(self, engine, **kwargs) -> None:
|
|
self.engine = engine
|
|
self.require_local_scope = kwargs["require_local_scope"]
|
|
self.preflight_calls: list[object] = []
|
|
|
|
def flat_preflight(self, scope) -> dict[str, object]:
|
|
"""记录 local 预检调用,供 OSS local 路径断言。"""
|
|
self.preflight_calls.append(scope)
|
|
return {}
|
|
|
|
class FakeRunner:
|
|
"""隔离生命周期测试中的外部 Agent runner。"""
|
|
|
|
def shutdown(self) -> None:
|
|
"""满足应用关闭协议。"""
|
|
|
|
class FakeWorkerManager:
|
|
"""记录进程级 manager 的 handler、启动和关闭动作。"""
|
|
|
|
last_instance = None
|
|
|
|
def __init__(self, resources, **kwargs) -> None:
|
|
del resources
|
|
type(self).last_instance = self
|
|
self.executor = kwargs["executor"]
|
|
self.owner = "test-worker"
|
|
self.registered: dict[str, object] = {}
|
|
self.started = False
|
|
self.stopped = False
|
|
self.task_resolver = None
|
|
self.scope_resolver = None
|
|
|
|
@property
|
|
def worker_count(self) -> int:
|
|
"""返回测试中唯一的进程级 manager 数量。"""
|
|
return 0 if self.stopped else 1
|
|
|
|
def set_service_resolvers(self, task_resolver, scope_resolver) -> None:
|
|
"""记录 factory 安装的持久任务和 scope facade 解析器。"""
|
|
self.task_resolver = task_resolver
|
|
self.scope_resolver = scope_resolver
|
|
|
|
def register(self, task_type, handler) -> None:
|
|
"""记录全局 handler 注册,不创建任何 scope facade。"""
|
|
self.registered[task_type] = handler
|
|
|
|
def start(self) -> dict[str, list[str]]:
|
|
"""记录 manager 启动并返回标准诊断结构。"""
|
|
self.started = True
|
|
return {"started": [self.owner], "invalid_tasks": []}
|
|
|
|
def shutdown(self) -> None:
|
|
"""记录 manager 关闭。"""
|
|
self.stopped = True
|
|
|
|
class DummyMetadata:
|
|
"""scope-bound 元数据 facade 替身。"""
|
|
|
|
def __init__(self, _resources, *, scope_id):
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
service_scope_calls.append(self.scope.scope_id)
|
|
self.blob_store = SimpleNamespace()
|
|
self.recovered = False
|
|
|
|
def recover_storage(self, *, limit: int) -> dict[str, int]:
|
|
"""记录 local 启动时的存储恢复调用。"""
|
|
del limit
|
|
self.recovered = True
|
|
return {}
|
|
|
|
class DummySearch:
|
|
"""scope-bound 搜索 facade 替身。"""
|
|
|
|
def __init__(self, _settings, _resources, _metadata, *, scope_id):
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
service_scope_calls.append(self.scope.scope_id)
|
|
|
|
class DummyTasks:
|
|
"""只记录共享 manager,不创建 scope 私有线程池。"""
|
|
|
|
def __init__(self, _resources, *, scope_id, worker_manager, **_kwargs):
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
service_scope_calls.append(self.scope.scope_id)
|
|
self.worker_manager = worker_manager
|
|
self.registered: dict[str, object] = {}
|
|
self.finalizer = None
|
|
|
|
def register(self, task_type, handler) -> None:
|
|
"""记录 facade 转发到进程级 registry 的 handler。"""
|
|
self.registered[task_type] = handler
|
|
self.worker_manager.register(task_type, handler)
|
|
|
|
def set_batch_finalizer(self, callback) -> None:
|
|
"""记录 scope 专属批次收束回调。"""
|
|
self.finalizer = callback
|
|
|
|
reverse_provider_bindings: list[object | None] = []
|
|
|
|
class DummyReverse:
|
|
"""scope-bound 反向图片 facade 替身。"""
|
|
|
|
def __init__(self, _settings, _resources, *, scope_id, provider=None, provider_binding=None, **_kwargs):
|
|
reverse_provider_bindings.append(provider_binding if provider_binding is not None else provider)
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
service_scope_calls.append(self.scope.scope_id)
|
|
|
|
class DummyVisual:
|
|
"""scope-bound 视觉 facade 替身。"""
|
|
|
|
def __init__(self, _settings, _resources, *, scope_id):
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
service_scope_calls.append(self.scope.scope_id)
|
|
|
|
engine = FakeEngine()
|
|
settings = FakeSettings()
|
|
resources: list[FakeResources] = []
|
|
check_calls: list[dict[str, object]] = []
|
|
service_scope_calls: list[str] = []
|
|
|
|
def make_resources(engine_arg, **kwargs):
|
|
"""保存生命周期实际传入的 local-scope 配置。"""
|
|
value = FakeResources(engine_arg, **kwargs)
|
|
resources.append(value)
|
|
return value
|
|
|
|
def check_database(_engine, **kwargs):
|
|
"""记录数据库启动门禁参数,不连接真实 PostgreSQL。"""
|
|
check_calls.append(kwargs)
|
|
return {"revision": "test", "pgvector": True}
|
|
|
|
monkeypatch.setattr(api_module.Settings, "from_env", classmethod(lambda cls: settings))
|
|
monkeypatch.setattr(api_module, "create_engine_for_settings", lambda _settings: engine)
|
|
monkeypatch.setattr(api_module, "check_database", check_database)
|
|
monkeypatch.setattr(api_module, "DatabaseResources", make_resources)
|
|
monkeypatch.setattr(api_module, "OpenCodeRunner", lambda _settings: FakeRunner())
|
|
monkeypatch.setattr(api_module, "OpenCodeActivityReader", lambda _root: object())
|
|
monkeypatch.setattr(api_module, "VisualInferenceClient", lambda _settings: object())
|
|
monkeypatch.setattr(api_module, "PostgresTaskWorkerManager", FakeWorkerManager)
|
|
monkeypatch.setattr(api_module, "PostgresMetadataService", DummyMetadata)
|
|
monkeypatch.setattr(api_module, "PostgresSearchService", DummySearch)
|
|
monkeypatch.setattr(api_module, "PostgresTaskService", DummyTasks)
|
|
monkeypatch.setattr(api_module, "ReverseImageService", DummyReverse)
|
|
monkeypatch.setattr(api_module, "VisualSearchService", DummyVisual)
|
|
return SimpleNamespace(
|
|
engine=engine,
|
|
settings=settings,
|
|
resources=resources,
|
|
check_calls=check_calls,
|
|
service_scope_calls=service_scope_calls,
|
|
reverse_provider_bindings=reverse_provider_bindings,
|
|
worker_class=FakeWorkerManager,
|
|
)
|
|
|
|
|
|
def test_local_scope_resolver_ignores_client_fields_and_is_immutable() -> None:
|
|
"""local 适配器不读取伪造字段,ScopeContext 不能被原地修改。"""
|
|
request = _request(LocalScopeResolver("local"))
|
|
request.query_params = {"scope_id": "scope-b", "user_id": "attacker"}
|
|
assert resolve_scope(request) == ScopeContext("local")
|
|
with pytest.raises((AttributeError, TypeError)):
|
|
resolve_scope(request).scope_id = "scope-b" # type: ignore[misc]
|
|
|
|
|
|
def test_missing_or_invalid_resolver_fails_closed() -> None:
|
|
"""缺失 resolver、异常 resolver 和空结果都不能回退 local。"""
|
|
with pytest.raises(ScopeResolutionError, match="未配置"):
|
|
resolve_scope(_request())
|
|
|
|
class BrokenResolver:
|
|
"""模拟宿主认证上下文异常。"""
|
|
|
|
def resolve(self, request):
|
|
"""返回无效结果以验证稳定失败。"""
|
|
del request
|
|
return None
|
|
|
|
with pytest.raises(ScopeResolutionError):
|
|
resolve_scope(_request(BrokenResolver()))
|
|
|
|
|
|
def test_create_app_requires_explicit_resolver() -> None:
|
|
"""应用工厂漏配 resolver 时直接 fail-closed。"""
|
|
with pytest.raises(ScopeResolutionError):
|
|
create_app(scope_resolver=None)
|
|
application = create_app(scope_resolver=LocalScopeResolver("local"))
|
|
assert application.state.scope_resolver.scope.scope_id == "local"
|
|
|
|
|
|
def test_scope_resolver_supports_explicit_scope_method_and_rejects_delimiter_controls() -> None:
|
|
"""宿主只实现 resolve_scope 时仍可注入,DEL 等控制字符不能进入 scope。"""
|
|
|
|
class ScopeMethodResolver:
|
|
"""模拟适配宿主的显式 resolver 方法。"""
|
|
|
|
resolve = "not-callable"
|
|
|
|
def resolve_scope(self, request):
|
|
"""返回可信 scope,证明备用方法不会被同名属性遮蔽。"""
|
|
del request
|
|
return "scope-a"
|
|
|
|
assert resolve_scope(_request(ScopeMethodResolver())) == ScopeContext("scope-a")
|
|
with pytest.raises(ValueError, match="scope_invalid"):
|
|
ScopeContext("scope-\x7f-a")
|
|
|
|
|
|
def test_create_app_keeps_host_factory_injection() -> None:
|
|
"""应用装配应保留宿主 factory,不能在构造阶段替换成隐式 local 服务。"""
|
|
factory = object()
|
|
provider = object()
|
|
application = create_app(scope_resolver=LocalScopeResolver("local"), service_factory=factory, agent_input_provider=provider)
|
|
assert application.state.service_factory is factory
|
|
assert application.state.agent_input_provider is provider
|
|
|
|
|
|
def test_create_app_freezes_reverse_image_provider_binding() -> None:
|
|
"""宿主 provider binding 只从应用工厂进入 state,模块级入口保持原行为。"""
|
|
binding = ReverseImageProviderBinding("host_provider", "visual_search", "verified", _empty_reverse_image_search)
|
|
application = create_app(scope_resolver=LocalScopeResolver("local"), reverse_image_provider_binding=binding)
|
|
|
|
assert application.state.reverse_image_provider_binding is binding
|
|
assert not hasattr(api_module.app.state, "reverse_image_provider_binding")
|
|
|
|
|
|
def test_create_app_rejects_binding_with_custom_service_factory() -> None:
|
|
"""自定义 factory 与公共 binding 不能同时声明,避免绑定被静默忽略。"""
|
|
binding = ReverseImageProviderBinding("host_provider", "visual_search", "verified", _empty_reverse_image_search)
|
|
|
|
with pytest.raises(ValueError, match="requires_managed_factory"):
|
|
create_app(scope_resolver=LocalScopeResolver("local"), service_factory=object(), reverse_image_provider_binding=binding)
|
|
|
|
|
|
def test_application_extension_registers_routes_once() -> None:
|
|
"""宿主扩展可以附加路由,且公共工厂不会复制业务路由。"""
|
|
from fastapi import FastAPI
|
|
|
|
class RouteExtension:
|
|
"""只注册一个测试路由的最小扩展。"""
|
|
|
|
def register_routes(self, app: FastAPI) -> None:
|
|
"""向应用注册扩展路由。"""
|
|
app.add_api_route("/extension-probe", lambda: {"ok": True}, methods=["GET"])
|
|
|
|
application = create_app(scope_resolver=LocalScopeResolver("local"), extensions=(RouteExtension(),))
|
|
paths = [getattr(route, "path", None) for route in application.routes]
|
|
assert paths.count("/extension-probe") == 1
|
|
|
|
|
|
def test_application_extension_lifecycle_runs_after_factory_and_before_shutdown(monkeypatch, tmp_path: Path) -> None:
|
|
"""扩展启动发生在公共服务就绪后,关闭发生在公共资源释放前。"""
|
|
doubles = _install_lifespan_doubles(monkeypatch, tmp_path)
|
|
events: list[tuple[str, bool]] = []
|
|
|
|
class LifecycleExtension:
|
|
"""记录扩展生命周期相对公共工厂的顺序。"""
|
|
|
|
async def on_startup(self, app) -> None:
|
|
"""记录启动时公共 factory 已完成装配。"""
|
|
events.append(("startup", callable(getattr(app.state.service_factory, "start_all", None))))
|
|
|
|
async def on_shutdown(self, app) -> None:
|
|
"""记录关闭时公共运行时仍可访问。"""
|
|
events.append(("shutdown", hasattr(app.state, "opencode")))
|
|
|
|
application = create_app(scope_resolver=_ClosedScopeResolver(), extensions=(LifecycleExtension(),))
|
|
|
|
async def exercise() -> None:
|
|
"""运行一次生命周期以收集扩展事件。"""
|
|
async with api_module.lifespan(application):
|
|
events.append(("body", doubles.worker_class.last_instance is not None))
|
|
|
|
asyncio.run(exercise())
|
|
assert events == [("startup", True), ("body", True), ("shutdown", True)]
|
|
|
|
|
|
def test_scope_exempt_extension_skips_resolver_and_authorizes_path() -> None:
|
|
"""scope 豁免路径只执行扩展的独立授权,不调用业务 resolver。"""
|
|
events: list[str] = []
|
|
|
|
class BrokenResolver:
|
|
"""一旦被调用就失败,证明豁免路径未解析 scope。"""
|
|
|
|
def resolve(self, _request):
|
|
"""返回不应到达的分支。"""
|
|
raise AssertionError("scope 豁免路径不应调用 resolver")
|
|
|
|
class ExemptExtension:
|
|
"""声明健康检查路径为 scope 豁免并记录授权。"""
|
|
|
|
def scope_exempt_paths(self):
|
|
"""返回精确豁免路径。"""
|
|
return ("/health",)
|
|
|
|
async def authorize_exempt_request(self, _request) -> None:
|
|
"""记录豁免路径已通过独立授权。"""
|
|
events.append("exempt-authorized")
|
|
|
|
app = SimpleNamespace(state=SimpleNamespace(scope_resolver=BrokenResolver(), extensions=(ExemptExtension(),)))
|
|
request = SimpleNamespace(
|
|
url=SimpleNamespace(path="/health"),
|
|
app=app,
|
|
query_params={},
|
|
headers={},
|
|
state=SimpleNamespace(),
|
|
)
|
|
|
|
async def call_next(_request):
|
|
"""记录豁免 handler 继续执行。"""
|
|
events.append("handler")
|
|
return "ok"
|
|
|
|
assert asyncio.run(bind_request_scope(request, call_next)) == "ok"
|
|
assert events == ["exempt-authorized", "handler"]
|
|
|
|
|
|
def test_scope_authorization_hook_runs_after_services_and_before_handler() -> None:
|
|
"""scope-bound 授权钩子收到完整服务视图,并先于业务 handler 执行。"""
|
|
events: list[str] = []
|
|
|
|
class Resolver:
|
|
"""返回固定可信 scope 的测试 resolver。"""
|
|
|
|
def resolve(self, _request):
|
|
"""记录 scope 解析并返回 scope-a。"""
|
|
events.append("resolver")
|
|
return "scope-a"
|
|
|
|
class Factory:
|
|
"""返回与 scope 一致的最小服务视图。"""
|
|
|
|
def for_scope(self, scope):
|
|
"""记录服务装配后返回绑定视图。"""
|
|
events.append("factory")
|
|
return _scope_services(scope.scope_id)
|
|
|
|
class Extension:
|
|
"""记录 scope-bound 授权钩子的参数。"""
|
|
|
|
async def authorize_request(self, _request, scope, services) -> None:
|
|
"""确认钩子收到解析后的 scope 和服务。"""
|
|
assert scope == ScopeContext("scope-a")
|
|
assert validate_scope_services(scope, services) is services
|
|
events.append("authorize")
|
|
|
|
app = SimpleNamespace(state=SimpleNamespace(scope_resolver=Resolver(), service_factory=Factory(), extensions=(Extension(),)))
|
|
request = SimpleNamespace(
|
|
url=SimpleNamespace(path="/images"),
|
|
app=app,
|
|
query_params={},
|
|
headers={},
|
|
state=SimpleNamespace(),
|
|
)
|
|
|
|
async def call_next(_request):
|
|
"""记录业务 handler 执行。"""
|
|
events.append("handler")
|
|
return "ok"
|
|
|
|
assert asyncio.run(bind_request_scope(request, call_next)) == "ok"
|
|
assert events == ["resolver", "factory", "authorize", "handler"]
|
|
assert request.state.scope == ScopeContext("scope-a")
|
|
|
|
|
|
def test_scope_authorization_hook_rejection_skips_handler() -> None:
|
|
"""scope-bound 授权拒绝时返回稳定响应且不进入业务 handler。"""
|
|
called: list[str] = []
|
|
|
|
class Extension:
|
|
"""拒绝所有 scope-bound 请求的测试扩展。"""
|
|
|
|
async def authorize_request(self, _request, _scope, _services) -> None:
|
|
"""返回明确的 HTTP 拒绝。"""
|
|
raise HTTPException(status_code=403, detail={"error": "extension_forbidden", "message": "请求未获授权"})
|
|
|
|
app = SimpleNamespace(state=SimpleNamespace(scope_resolver=lambda _request: "scope-a", service_factory=SimpleNamespace(for_scope=lambda scope: _scope_services(scope.scope_id)), extensions=(Extension(),)))
|
|
request = SimpleNamespace(
|
|
url=SimpleNamespace(path="/images"),
|
|
app=app,
|
|
query_params={},
|
|
headers={},
|
|
state=SimpleNamespace(),
|
|
)
|
|
|
|
async def call_next(_request):
|
|
"""该 handler 不应被授权失败路径调用。"""
|
|
called.append("handler")
|
|
raise AssertionError("授权失败后不应执行业务 handler")
|
|
|
|
response = asyncio.run(bind_request_scope(request, call_next))
|
|
assert response.status_code == 403
|
|
assert called == []
|
|
|
|
|
|
def test_host_lifespan_never_requires_local_scope_or_preflight(monkeypatch, tmp_path: Path) -> None:
|
|
"""宿主 factory 启动不调用 local 装配,也允许数据库没有 local namespace。"""
|
|
class FakeEngine:
|
|
"""记录生命周期是否正确释放共享 Engine。"""
|
|
|
|
def __init__(self) -> None:
|
|
self.disposed = False
|
|
|
|
def dispose(self) -> None:
|
|
"""记录 dispose 调用。"""
|
|
self.disposed = True
|
|
|
|
class FakeSettings:
|
|
"""提供 host lifespan 所需的最小只读配置。"""
|
|
|
|
image_root = tmp_path / "host-images"
|
|
data_root = tmp_path / "host-data"
|
|
opencode_runtime_root = tmp_path / "host-runtime"
|
|
database_url = "postgresql+psycopg://host/test"
|
|
expected_database_revision = "test-revision"
|
|
|
|
def ensure_directories(self) -> None:
|
|
"""host 仍需要创建运行时目录,但不创建 local scope。"""
|
|
self.data_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
class FakeResources:
|
|
"""拒绝任何未授权的 local preflight 调用。"""
|
|
|
|
def __init__(self, engine, **kwargs) -> None:
|
|
self.require_local_scope = kwargs["require_local_scope"]
|
|
self.preflight_calls: list[object] = []
|
|
|
|
def flat_preflight(self, scope) -> dict[str, object]:
|
|
"""记录调用,测试应断言该方法从未被调用。"""
|
|
self.preflight_calls.append(scope)
|
|
raise AssertionError("host lifespan 不应执行 local preflight")
|
|
|
|
class FakeRunner:
|
|
"""隔离测试中的外部 Agent runner。"""
|
|
|
|
def shutdown(self) -> None:
|
|
"""满足 lifespan 关闭协议。"""
|
|
|
|
class HostFactory:
|
|
"""只提供 scope-aware factory 协议,不提供 local scope。"""
|
|
|
|
def __init__(self) -> None:
|
|
self.for_scope_calls: list[object] = []
|
|
self.started = False
|
|
self.stopped = False
|
|
|
|
def for_scope(self, scope):
|
|
"""任何 local 调用都说明 host 启动仍有隐式 fallback。"""
|
|
self.for_scope_calls.append(scope)
|
|
if getattr(scope, "scope_id", scope) == "local":
|
|
raise AssertionError("host factory 不应装配 local")
|
|
raise AssertionError("测试不应在 lifespan 内创建业务 scope")
|
|
|
|
def for_task(self, task_id):
|
|
"""提供内部 callback 所需的协议占位。"""
|
|
del task_id
|
|
return None
|
|
|
|
def start_all(self):
|
|
"""host 自己负责全局 Worker 启动。"""
|
|
self.started = True
|
|
return {"started": ["host-worker"], "invalid_tasks": []}
|
|
|
|
def shutdown(self) -> None:
|
|
"""记录 host factory 关闭。"""
|
|
self.stopped = True
|
|
|
|
engine = FakeEngine()
|
|
settings = FakeSettings()
|
|
resources: list[FakeResources] = []
|
|
factory = HostFactory()
|
|
check_calls: list[dict[str, object]] = []
|
|
|
|
def make_resources(engine_arg, **kwargs):
|
|
"""保存实际传入的 host local-scope 开关。"""
|
|
value = FakeResources(engine_arg, **kwargs)
|
|
resources.append(value)
|
|
return value
|
|
|
|
monkeypatch.setattr(api_module.Settings, "from_env", classmethod(lambda cls: settings))
|
|
monkeypatch.setattr(api_module, "create_engine_for_settings", lambda _settings: engine)
|
|
monkeypatch.setattr(api_module, "check_database", lambda _engine, **kwargs: (check_calls.append(kwargs) or {"revision": "test", "pgvector": True}))
|
|
monkeypatch.setattr(api_module, "DatabaseResources", make_resources)
|
|
monkeypatch.setattr(api_module, "OpenCodeRunner", lambda _settings: FakeRunner())
|
|
monkeypatch.setattr(api_module, "OpenCodeActivityReader", lambda _root: object())
|
|
monkeypatch.setattr(api_module, "VisualInferenceClient", lambda _settings: object())
|
|
|
|
application = create_app(scope_resolver=_ClosedScopeResolver(), service_factory=factory)
|
|
asyncio.run(_exercise_lifespan(application, factory, resources, engine))
|
|
assert check_calls[0]["require_local_installation"] is False
|
|
|
|
|
|
async def _exercise_lifespan(application, factory, resources, engine) -> None:
|
|
"""运行 host lifespan 并断言 local 资源从未被访问。"""
|
|
async with api_module.lifespan(application):
|
|
assert resources[0].require_local_scope is False
|
|
assert resources[0].preflight_calls == []
|
|
assert factory.for_scope_calls == []
|
|
assert factory.started is True
|
|
assert not hasattr(application.state, "metadata")
|
|
assert not hasattr(application.state, "tasks")
|
|
assert factory.stopped is True
|
|
assert engine.disposed is True
|
|
|
|
|
|
def test_nonlocal_resolver_with_default_factory_never_preloads_local(monkeypatch, tmp_path: Path) -> None:
|
|
"""非 local resolver 使用核心默认 factory 时不创建、预检或缓存 local facade。"""
|
|
doubles = _install_lifespan_doubles(monkeypatch, tmp_path)
|
|
application = create_app(scope_resolver=_ClosedScopeResolver())
|
|
|
|
async def exercise() -> None:
|
|
"""运行默认 factory 生命周期并检查启动前后的 scope 边界。"""
|
|
async with api_module.lifespan(application):
|
|
manager = doubles.worker_class.last_instance
|
|
assert manager is not None
|
|
assert manager.started is True
|
|
assert set(manager.registered) == {"cache_generation", "metadata_repair", "derived_thumbnail_generation", "visual_embedding_generation", "meme_context_generation", "image_auto_rename"}
|
|
assert doubles.resources[0].require_local_scope is False
|
|
assert doubles.resources[0].preflight_calls == []
|
|
assert doubles.check_calls[0]["require_local_installation"] is False
|
|
assert not hasattr(application.state, "resolver")
|
|
assert not hasattr(application.state, "metadata")
|
|
assert not hasattr(application.state, "tasks")
|
|
assert doubles.service_scope_calls == []
|
|
assert isinstance(application.state.service_factory, ScopeServiceFactory)
|
|
assert application.state.service_factory._services == {}
|
|
|
|
asyncio.run(exercise())
|
|
manager = doubles.worker_class.last_instance
|
|
assert manager is not None and manager.stopped is True
|
|
assert doubles.engine.disposed is True
|
|
|
|
|
|
def test_explicit_oss_local_resolver_keeps_local_preflight_and_facade(monkeypatch, tmp_path: Path) -> None:
|
|
"""只有显式 OSS LocalScopeResolver("local") 才保留 local 预检和默认 facade。"""
|
|
assert isinstance(api_module.app.state.scope_resolver, LocalScopeResolver)
|
|
assert api_module.app.state.scope_resolver.scope.scope_id == "local"
|
|
doubles = _install_lifespan_doubles(monkeypatch, tmp_path)
|
|
application = create_app(scope_resolver=LocalScopeResolver("local"))
|
|
|
|
async def exercise() -> None:
|
|
"""运行 OSS local 生命周期并检查 local 资源确实被显式装配。"""
|
|
async with api_module.lifespan(application):
|
|
manager = doubles.worker_class.last_instance
|
|
assert manager is not None
|
|
assert manager.started is True
|
|
assert doubles.resources[0].require_local_scope is True
|
|
assert doubles.resources[0].preflight_calls == [ScopeContext("local")]
|
|
assert doubles.check_calls[0]["require_local_installation"] is True
|
|
assert hasattr(application.state, "resolver")
|
|
assert hasattr(application.state, "metadata")
|
|
assert hasattr(application.state, "tasks")
|
|
assert doubles.service_scope_calls == ["local"] * 5
|
|
assert set(application.state.service_factory._services) == {"local"}
|
|
assert application.state.service_factory._services["local"].metadata.recovered is True
|
|
|
|
asyncio.run(exercise())
|
|
manager = doubles.worker_class.last_instance
|
|
assert manager is not None and manager.stopped is True
|
|
assert doubles.engine.disposed is True
|
|
|
|
|
|
def test_lifecycle_passes_provider_binding_to_local_service_and_scope_factory(monkeypatch, tmp_path: Path) -> None:
|
|
"""生命周期把同一宿主绑定交给 local 服务和后续 scope factory。"""
|
|
binding = ReverseImageProviderBinding("host_provider", "visual_search", "verified", _empty_reverse_image_search)
|
|
doubles = _install_lifespan_doubles(monkeypatch, tmp_path)
|
|
application = create_app(scope_resolver=LocalScopeResolver("local"), reverse_image_provider_binding=binding)
|
|
|
|
async def exercise() -> None:
|
|
"""运行生命周期并检查两个公共装配位置收到同一绑定。"""
|
|
async with api_module.lifespan(application):
|
|
assert doubles.reverse_provider_bindings == [binding]
|
|
assert application.state.service_factory._task_config["reverse_provider_binding"] is binding
|
|
|
|
asyncio.run(exercise())
|
|
|
|
|
|
def test_lifecycle_compatibility_fallback_keeps_provider_binding(monkeypatch, tmp_path: Path) -> None:
|
|
"""旧 facade 不接受 policy 参数时,兼容重试仍必须保留 provider binding。"""
|
|
binding = ReverseImageProviderBinding("host_provider", "visual_search", "verified", _empty_reverse_image_search)
|
|
doubles = _install_lifespan_doubles(monkeypatch, tmp_path)
|
|
captured: list[object | None] = []
|
|
|
|
class LegacyReverse:
|
|
"""模拟只支持 scope 和新 provider binding 的轻量 facade。"""
|
|
|
|
def __init__(self, _settings, _resources, *, scope_id, provider_binding=None):
|
|
captured.append(provider_binding)
|
|
self.scope = scope_id if isinstance(scope_id, ScopeContext) else ScopeContext(scope_id)
|
|
|
|
monkeypatch.setattr(api_module, "ReverseImageService", LegacyReverse)
|
|
application = create_app(scope_resolver=LocalScopeResolver("local"), reverse_image_provider_binding=binding)
|
|
|
|
async def exercise() -> None:
|
|
"""触发首次 TypeError 和兼容重试,验证绑定没有被丢弃。"""
|
|
async with api_module.lifespan(application):
|
|
assert captured == [binding]
|
|
|
|
asyncio.run(exercise())
|
|
assert doubles.engine.disposed is True
|
|
|
|
|
|
def test_many_scope_facades_share_one_process_worker_manager(monkeypatch) -> None:
|
|
"""大量 scope 只创建轻量 facade,不复制 Worker、线程池或 owner。"""
|
|
import backend.pg_services as pg_services
|
|
import backend.reverse_image as reverse_image
|
|
import backend.visual as visual
|
|
|
|
class DummyMetadata:
|
|
"""scope facade 测试替身。"""
|
|
|
|
def __init__(self, _resources, *, scope_id):
|
|
self.scope = scope_id
|
|
self.blob_store = SimpleNamespace()
|
|
|
|
class DummySearch:
|
|
"""搜索 facade 测试替身。"""
|
|
|
|
def __init__(self, _settings, _resources, _metadata, *, scope_id):
|
|
self.scope = scope_id
|
|
|
|
class DummyTasks:
|
|
"""只记录共享 manager,不创建自己的 executor。"""
|
|
|
|
def __init__(self, _resources, *, scope_id, worker_manager, **_kwargs):
|
|
self.scope = ScopeContext(scope_id)
|
|
self.worker_manager = worker_manager
|
|
|
|
def register(self, _task_type, _handler):
|
|
"""兼容 factory 的 handler 注册钩子。"""
|
|
|
|
def set_batch_finalizer(self, _callback):
|
|
"""兼容批次 finalizer 注册钩子。"""
|
|
|
|
captured_bindings: list[object | None] = []
|
|
|
|
class DummyReverse:
|
|
"""反向图片 facade 测试替身。"""
|
|
|
|
def __init__(self, _settings, _resources, *, scope_id, provider=None, provider_binding=None):
|
|
self.scope = scope_id
|
|
captured_bindings.append(provider_binding if provider_binding is not None else provider)
|
|
|
|
class DummyVisual:
|
|
"""视觉 facade 测试替身。"""
|
|
|
|
def __init__(self, _settings, _resources, *, scope_id):
|
|
self.scope = scope_id
|
|
|
|
monkeypatch.setattr(pg_services, "PostgresMetadataService", DummyMetadata)
|
|
monkeypatch.setattr(pg_services, "PostgresSearchService", DummySearch)
|
|
monkeypatch.setattr(pg_services, "PostgresTaskService", DummyTasks)
|
|
monkeypatch.setattr(reverse_image, "ReverseImageService", DummyReverse)
|
|
monkeypatch.setattr(visual, "VisualSearchService", DummyVisual)
|
|
|
|
class EmptySession:
|
|
"""让 manager 关闭路径无需真实数据库即可验证 executor 生命周期。"""
|
|
|
|
def __enter__(self):
|
|
"""返回空 session。"""
|
|
return self
|
|
|
|
def __exit__(self, *_args):
|
|
"""结束空 session。"""
|
|
|
|
def scalars(self, _statement):
|
|
"""关闭测试没有 running 任务。"""
|
|
return []
|
|
|
|
def commit(self) -> None:
|
|
"""兼容 manager 的事务提交。"""
|
|
|
|
settings = SimpleNamespace(opencode_concurrency=2, agent_backpressure=32, settings_version="test", worker_lease_seconds=120, worker_max_attempts=3)
|
|
manager_resources = SimpleNamespace(factory=lambda: EmptySession())
|
|
binding = ReverseImageProviderBinding("host_provider", "visual_search", "verified", _empty_reverse_image_search)
|
|
factory = ScopeServiceFactory(manager_resources, settings, task_config={"reverse_provider_binding": binding})
|
|
try:
|
|
services = [factory.for_scope(f"scope-{index}") for index in range(100)]
|
|
assert factory._services == {}
|
|
assert len({id(service.tasks.worker_manager) for service in services}) == 1
|
|
assert factory._worker_manager.worker_count == 1
|
|
assert factory._worker_manager.owner == services[0].tasks.worker_manager.owner
|
|
assert captured_bindings == [binding] * 100
|
|
finally:
|
|
factory.shutdown()
|
|
|
|
|
|
def test_internal_callback_does_not_require_user_request_scope() -> None:
|
|
"""内部 callback 由 task 控制面恢复 scope,不应被公共 resolver 提前拦截。"""
|
|
state = SimpleNamespace(service_factory=SimpleNamespace(for_task=lambda _task_id: None))
|
|
request = SimpleNamespace(url=SimpleNamespace(path="/internal/reverse-image/search"), app=SimpleNamespace(state=state))
|
|
called: list[bool] = []
|
|
|
|
async def call_next(_request):
|
|
"""记录 callback 已进入路由层。"""
|
|
called.append(True)
|
|
return "ok"
|
|
|
|
assert asyncio.run(bind_request_scope(request, call_next)) == "ok"
|
|
assert called == [True]
|
|
|
|
|
|
def test_public_scope_middleware_fails_closed_without_resolver() -> None:
|
|
"""公共请求缺少 resolver 时在业务路由前返回稳定 503。"""
|
|
state = SimpleNamespace(service_factory=SimpleNamespace(for_scope=lambda _scope: None))
|
|
request = SimpleNamespace(url=SimpleNamespace(path="/images"), app=SimpleNamespace(state=state))
|
|
|
|
async def call_next(_request):
|
|
"""该回调不应在 scope 解析失败时被调用。"""
|
|
raise AssertionError("业务路由不应执行")
|
|
|
|
response = asyncio.run(bind_request_scope(request, call_next))
|
|
assert response.status_code == 503
|
|
|
|
|
|
def test_task_scope_is_internal_and_never_serialized_as_public_field() -> None:
|
|
"""任务快照可以内部保存 scope,但公开结构不能把授权事实回显给客户端。"""
|
|
record = TaskRecord(task_id="task-1", task_type="cache_generation", scope_id="scope-a")
|
|
assert record.scope_id == "scope-a"
|
|
assert "scope_id" not in record.as_dict()
|
|
|
|
|
|
def _scope_services(scope_id: str, *, child_scope_id: str | None = None) -> ScopeServices:
|
|
"""构造只含 scope 声明的最小服务视图,供一致性测试使用。"""
|
|
child_scope = ScopeContext(child_scope_id or scope_id)
|
|
child = SimpleNamespace(scope=child_scope, blob_store=SimpleNamespace(scope=child_scope))
|
|
return ScopeServices(
|
|
ScopeContext(scope_id),
|
|
child,
|
|
child,
|
|
child,
|
|
child,
|
|
child,
|
|
)
|
|
|
|
|
|
def test_scope_service_validation_rejects_outer_and_child_scope_mismatch() -> None:
|
|
"""factory 返回错误外层或子服务 scope 时必须在业务访问前失败。"""
|
|
with pytest.raises(ScopeServicesError, match="外层"):
|
|
validate_scope_services("scope-a", _scope_services("scope-b"))
|
|
with pytest.raises(ScopeServicesError, match="子服务"):
|
|
validate_scope_services("scope-a", _scope_services("scope-a", child_scope_id="scope-b"))
|
|
|
|
|
|
def test_worker_factory_mismatch_settles_only_the_complete_claim() -> None:
|
|
"""Worker 装配返回错误 scope 时只用完整 claim fencing 收束自身任务。"""
|
|
from backend.pg_services import PostgresTaskWorkerManager
|
|
|
|
manager = PostgresTaskWorkerManager.__new__(PostgresTaskWorkerManager)
|
|
claim = SimpleNamespace(
|
|
id="task-a",
|
|
scope_id="scope-a",
|
|
claim_generation=7,
|
|
lease_owner="worker-a",
|
|
lease_expires_at=object(),
|
|
)
|
|
settled: list[object] = []
|
|
finished: list[tuple[str, bool]] = []
|
|
manager._scope_service_resolver = lambda _scope: _scope_services("scope-b")
|
|
manager._claim_for_task = lambda _task_id: claim
|
|
manager._fail_unresolvable = lambda value: settled.append(value)
|
|
manager._task_finished = lambda task_id, *, claimed: finished.append((task_id, claimed))
|
|
manager._run("task-a")
|
|
assert settled == [claim]
|
|
assert finished == [("task-a", True)]
|
|
|
|
|
|
def test_worker_reclaim_after_delayed_factory_failure_does_not_settle_new_claim() -> None:
|
|
"""装配异常延迟到重新认领后,旧 claim 的收束不能覆盖新 generation。"""
|
|
from backend.pg_services import PostgresTaskWorkerManager
|
|
|
|
manager = PostgresTaskWorkerManager.__new__(PostgresTaskWorkerManager)
|
|
first = SimpleNamespace(id="task-a", scope_id="scope-a", claim_generation=7)
|
|
second = SimpleNamespace(id="task-a", scope_id="scope-a", claim_generation=8)
|
|
claims = iter((first, second))
|
|
settled: list[object] = []
|
|
finished: list[tuple[str, bool]] = []
|
|
executed: list[tuple[str, object]] = []
|
|
|
|
class TaskFacade:
|
|
"""记录第二次有效 claim 的执行,避免触碰数据库。"""
|
|
|
|
scope = ScopeContext("scope-a")
|
|
|
|
def _run(self, task_id, *, preclaimed):
|
|
"""记录有效 claim,证明旧 claim 未被复用。"""
|
|
executed.append((task_id, preclaimed))
|
|
|
|
valid = _scope_services("scope-a")
|
|
valid = ScopeServices(valid.scope, valid.metadata, valid.search, TaskFacade(), valid.reverse_image, valid.visual_search)
|
|
manager._claim_for_task = lambda _task_id: next(claims)
|
|
resolver_calls = 0
|
|
|
|
def resolve_services(_scope):
|
|
"""第一次模拟延迟装配异常,第二次返回重新认领后的有效视图。"""
|
|
nonlocal resolver_calls
|
|
resolver_calls += 1
|
|
if resolver_calls == 1:
|
|
raise RuntimeError("late assembly")
|
|
return valid
|
|
|
|
manager._scope_service_resolver = resolve_services
|
|
manager._fail_unresolvable = lambda value: settled.append(value)
|
|
manager._task_finished = lambda task_id, *, claimed: finished.append((task_id, claimed))
|
|
manager._run("task-a")
|
|
manager._run("task-a")
|
|
assert settled == [first]
|
|
assert executed == [("task-a", second)]
|
|
# 第二次有效 claim 的收束由 scope task facade 自己负责,manager 不能再次
|
|
# 把它当作装配失败 claim 终止。
|
|
assert finished == [("task-a", True)]
|
|
|
|
|
|
def test_worker_successful_claim_settlement_releases_only_matching_lane_slot() -> None:
|
|
"""有效 claim 的装配失败会释放自身槽位,fencing 不匹配时保留他人槽位。"""
|
|
from backend.pg_services import PostgresTaskWorkerManager
|
|
|
|
claim = SimpleNamespace(id="task-a", scope_id="scope-a", claim_generation=7)
|
|
task = SimpleNamespace(
|
|
id="task-a",
|
|
scope_id="scope-a",
|
|
status="running",
|
|
completed_at=None,
|
|
lease_owner="worker-a",
|
|
lease_expires_at=object(),
|
|
message=None,
|
|
error=None,
|
|
)
|
|
slot = SimpleNamespace(
|
|
task_scope_id="scope-a",
|
|
task_id="task-a",
|
|
lease_owner="worker-a",
|
|
claim_generation=7,
|
|
lease_expires_at=object(),
|
|
)
|
|
|
|
class Session:
|
|
"""按查询顺序返回当前 claim 行和其 lane 槽位。"""
|
|
|
|
def __init__(self):
|
|
self.calls = 0
|
|
self.committed = False
|
|
|
|
def scalar(self, _statement):
|
|
"""提供锁定查询的最小替身。"""
|
|
self.calls += 1
|
|
return task if self.calls == 1 else slot
|
|
|
|
def commit(self):
|
|
"""记录事务收束。"""
|
|
self.committed = True
|
|
|
|
class Resources:
|
|
"""提供 manager 所需的短事务上下文。"""
|
|
|
|
def __init__(self, session):
|
|
self.session = session
|
|
|
|
def factory(self):
|
|
"""返回同一个 fake session。"""
|
|
class Context:
|
|
def __enter__(inner_self):
|
|
return self.session
|
|
|
|
def __exit__(inner_self, *_args):
|
|
return None
|
|
|
|
return Context()
|
|
|
|
manager = PostgresTaskWorkerManager.__new__(PostgresTaskWorkerManager)
|
|
manager.owner = "worker-a"
|
|
session = Session()
|
|
manager.resources = Resources(session)
|
|
manager._fail_unresolvable(claim)
|
|
assert task.status == "failed"
|
|
assert task.lease_owner is None
|
|
assert task.lease_expires_at is None
|
|
assert slot.task_id is None
|
|
assert slot.lease_owner is None
|
|
assert session.committed is True
|
|
|
|
foreign_slot = SimpleNamespace(
|
|
task_scope_id="scope-a",
|
|
task_id="task-a",
|
|
lease_owner="worker-b",
|
|
claim_generation=8,
|
|
lease_expires_at=object(),
|
|
)
|
|
|
|
class SlotSession:
|
|
"""只返回另一个 Worker 的槽位,验证 fencing 拒绝不会清空它。"""
|
|
|
|
def scalar(self, _statement):
|
|
"""返回被保护的槽位。"""
|
|
return foreign_slot
|
|
|
|
assert PostgresTaskWorkerManager._release_slot(SlotSession(), "scope-a", "task-a", owner="worker-a", claim_generation=7) is False
|
|
assert foreign_slot.task_id == "task-a"
|
|
assert foreign_slot.lease_owner == "worker-b"
|