Files
MoviePilot/tests/conftest.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

911 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
pytest 全局引导、隔离装配、数据库测试载具与资源收尾。
配置目录隔离及网络守卫复用 app.testing;当前模块在隔离数据库上装配应用端口,
为测试提供配置替换、缓存重装配和精确数据清理能力,不执行完整应用启动流程。
"""
import asyncio
import sys
from collections.abc import Awaitable, Callable
from functools import partial
from typing import TypeVar
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
# 必须早于首个牵入 app.runtime.config 的 import(app.db / app.chain.* 都会牵入):引擎本身已惰性,
# import app.db 不再连库,但 settings 在 import 期就把 CONFIG_DIR 读进字段并建好配置目录,之后
# 改环境变量已经晚了。prepare_backend 内部先隔离 CONFIG_DIR、补 app.application.site.sites 垫片,
# 再建表。app/testing 仅依赖标准库、import 不触发 app.*,故此处先 import 再调用是安全的。
from app.testing.bootstrap import prepare_backend
prepare_backend()
# 隔离配置后才导入自动启用的网络守卫;pytest 需要保留此未直接调用的导入。
from app.testing.network import block_real_network # noqa: E402,F401
TResult = TypeVar("TResult")
class _TestDatabaseExecutor:
"""
为未执行完整启动流程的测试提供同步数据库操作的线程入口。
"""
async def run(self, operation):
"""
在线程中执行一个同步数据库操作。
:param operation: 无需额外参数的同步回调,其事务边界由回调定义
:return: 回调结果;回调异常向上传播
"""
return await asyncio.to_thread(operation)
class _TestRuntimeSettingsProxy:
"""
把历史配置替换点转发到隔离测试使用的配置对象。
记录每个字段首次写入前的值;删除代理属性时恢复原值或删除新增字段。
直接赋值不会在代理销毁时自动恢复,测试仍需使用可撤销的替换工具。
:ivar _originals: 字段名到是否原先存在及原值的映射
"""
def __init__(self) -> None:
"""
创建用于恢复被替换配置字段的记录表。
"""
self._originals: dict[str, tuple[bool, object]] = {}
def __getattr__(self, key: str):
"""
从隔离测试配置中读取字段。
:param key: 配置字段名
:return: 当前字段值;不存在时保留原 AttributeError
"""
from app.runtime.config import settings
return getattr(settings, key)
def __setattr__(self, key: str, value):
"""
记录字段原值后写入隔离测试配置。
同一字段多次写入仍保留首次写入前的值。
:param key: 被替换的配置字段名;_originals 属于代理自身
:param value: 测试需要使用的临时字段值
"""
if key == "_originals":
object.__setattr__(self, key, value)
return
from app.runtime.config import settings
if key not in self._originals:
self._originals[key] = (hasattr(settings, key), getattr(settings, key, None))
setattr(settings, key, value)
def __delattr__(self, key: str) -> None:
"""
撤销一个已登记的配置字段替换。
:raises AttributeError: 字段没有可恢复的替换记录
:param key: 此前通过代理写入的字段名
"""
if key in self._originals:
from app.runtime.config import settings
had_value, original = self._originals.pop(key)
if had_value:
setattr(settings, key, original)
elif hasattr(settings, key):
delattr(settings, key)
return
raise AttributeError(key)
@pytest.fixture
def compose_cache_backend():
"""
提供缓存后端重装配入口,并在用例结束时恢复原配置和装配。
仅修改配置不会切换已创建的缓存路由,必须重新调用启动组合根。恢复不依赖其他夹具撤销顺序。
:yields: 接受缓存类型字符串的同步装配函数
"""
from app.runtime.config import settings
from app.startup.composition.cache import configure_cache_composition
original = settings.CACHE_BACKEND_TYPE
def compose(backend_type: str) -> None:
"""
设置缓存类型并立即重装配平台缓存。
:param backend_type: 运行配置支持的缓存类型标识
"""
settings.CACHE_BACKEND_TYPE = backend_type
configure_cache_composition()
try:
yield compose
finally:
settings.CACHE_BACKEND_TYPE = original
configure_cache_composition()
@pytest.fixture(autouse=True)
def install_runtime_settings_test_proxies(monkeypatch):
"""
向已导入的历史配置替换点注入本用例专用代理。
配置字段本身仍由测试使用的替换工具恢复,代理不会在销毁时自动回滚全部字段。
:yields: 已安装配置代理的测试执行阶段,不提供额外值
:param monkeypatch: 负责用例结束时撤销模块属性注入的 pytest 夹具
"""
proxy = _TestRuntimeSettingsProxy()
_install_runtime_settings_test_proxies(proxy, monkeypatch)
yield
def _install_runtime_settings_test_proxies(proxy, monkeypatch=None) -> None:
"""
为已导入且使用运行配置接口的宿主模块补充测试替换点。
仅扫描模块、Agent、启动、适配器及主入口;已有 settings 属性时不覆盖。
:param proxy: 用于转发配置字段读写的测试对象
:param monkeypatch: 可选的属性撤销工具;None 时直接写模块属性并保留到后续清理
"""
for module_name, module in tuple(sys.modules.items()):
if not (
module_name.startswith("app.modules.")
or module_name.startswith("app.agent.")
or module_name.startswith("app.startup.")
or module_name == "app.main"
or module_name.startswith("app.adapters.")
):
continue
if hasattr(module, "get_runtime_setting") and "settings" not in vars(module):
if monkeypatch is None:
setattr(module, "settings", proxy)
else:
monkeypatch.setattr(module, "settings", proxy, raising=False)
def pytest_runtest_call(item):
"""
在用例执行前为显式夹具新导入的模块补充配置替换点。
直接注入代理,不使用自动撤销工具;配置字段的替换仍由测试自身负责恢复。
:param item: pytest 当前用例节点,当前实现不读取其内容
"""
_install_runtime_settings_test_proxies(_TestRuntimeSettingsProxy())
@pytest.fixture(autouse=True)
def configure_plugin_system_services():
"""
为单元测试装配应用、存储和插件端口,复用隔离数据库。
替换运行配置、事务、Chain、Agent 与插件服务的进程级提供方;不执行完整应用生命周期。
收尾显式重置 Chain 网络端口、Chain 端口、插件系统及插件运行时工厂;其他测试专用装配
由下一用例重装或会话结束清理,不承诺恢复到夹具调用前的全部全局状态。
:yields: 应用端口已装配的测试执行阶段,不提供额外值
"""
from app.adapters.web.security.access import configure_token_codec
from app.api.data import configure_api_data_ports
from app.application.configuration import (
RuntimeConfiguration,
RuntimeSettingsService,
SystemConfigService,
TransferRetryConfig,
configure_runtime_configuration,
configure_runtime_settings,
configure_system_config,
configure_token_runtime_config,
configure_transfer_retry_config,
)
from app.application.security.token import (
create_access_token,
decode_access_token,
)
from app.application.security.userconfig import (
UserConfigurationService,
configure_user_configuration,
)
from app.application.service import configure_service_directory
from app.db.adapters.configuration import TransactionalUserConfigurationRepository
from app.db.oper.systemconfig import SystemConfigOper
from app.db.session import (
SessionFactory,
async_session_scope,
get_async_db,
get_db,
)
from app.db.uow import (
SqlAlchemyAsyncUnitOfWork,
SqlAlchemyUnitOfWork,
configure_transaction_runners,
)
from app.runtime.config import settings
from app.runtime.settings import configure_runtime_setting_provider
from app.startup.composition.configuration import (
build_api_runtime_config,
build_chain_runtime_config,
build_scheduler_runtime_config,
build_token_runtime_config,
)
from app.startup.composition.subscription import (
async_rule_group_mutation_scope,
delete_subscribe_scope,
rule_group_mutation_scope,
site_reference_mutation_scope,
subscription_completion_scope,
subscription_mutation_scope,
sync_delete_subscribe_scope,
sync_subscription_mutation_scope,
)
configure_token_codec(create_access_token, decode_access_token)
configure_runtime_configuration(
RuntimeConfiguration(
api=lambda: build_api_runtime_config(settings),
scheduler=lambda: build_scheduler_runtime_config(settings),
chain=lambda: build_chain_runtime_config(settings),
)
)
configure_runtime_settings(RuntimeSettingsService(settings))
configure_runtime_setting_provider(lambda key: getattr(settings, key))
configure_token_runtime_config(lambda: build_token_runtime_config(settings))
database_executor = _TestDatabaseExecutor()
system_config = SystemConfigOper()
user_config = TransactionalUserConfigurationRepository(SessionFactory)
with SessionFactory() as session:
system_config.load_snapshot(session)
user_config.load_snapshot()
configure_system_config(
SystemConfigService(
repository=system_config,
async_executor=database_executor,
)
)
configure_user_configuration(
UserConfigurationService(
repository=user_config,
async_executor=database_executor,
)
)
configure_transfer_retry_config(
lambda: TransferRetryConfig(
max_failed_retries=settings.TRANSFER_MAX_FAILED_RETRIES,
)
)
from app.application.chain.context import (
ChainRuntimeContext,
configure_chain_runtime_context_provider,
)
from app.application.messaging.chat import (
AgentChatPersistenceService,
AgentChatService,
configure_agent_chat_persistence,
configure_agent_chat_service,
)
from app.application.messaging.message import MessageHelper, MessageQueueManager
from app.application.module import configure_module_runtime
from app.application.plugin.runtime import configure_plugin_runtime
from app.runtime.cache import AsyncFileCache, FileCache
from app.runtime.events import EventManager
from app.runtime.extensions.module.dispatcher import ModuleInvocationDispatcher
from app.runtime.extensions.module.manager import ModuleManager
from app.runtime.extensions.plugin import manager as plugin_manager_module
from app.runtime.extensions.plugin.database import get_plugin_database
from app.runtime.extensions.plugin.manager import (
PluginManager,
reset_plugin_runtime_factory,
)
from app.runtime.extensions.plugin.runtime import (
PluginRuntimeEnvironment,
build_plugin_runtime,
)
from app.runtime.extensions.plugin.storage import (
get_plugin_instance_directory,
get_plugin_storage,
)
from app.runtime.extensions.plugin.system import get_plugin_system
from app.runtime.extensions.service import ServiceConfigHelper
from app.startup.initializers.plugins import (
_clear_plugin_default_target,
_read_plugin_runtime_declaration,
_set_plugin_default_target,
)
configure_service_directory(
configs=ServiceConfigHelper.get_configs,
modules=lambda module_type: ModuleManager().get_running_type_modules(module_type),
)
def build_test_plugin_runtime(host):
"""
在测试组合根构造插件运行时及其依赖适配器。
:param host: 需要插件运行时的管理器实例,提供远端入口查询
:return: 绑定测试配置、存储及生命周期钩子的插件运行时
"""
return build_plugin_runtime(
host,
PluginRuntimeEnvironment(
plugins_root=settings.ROOT_PATH / "app" / "plugins",
storage=get_plugin_storage,
instance_directory=get_plugin_instance_directory,
system=get_plugin_system,
database=get_plugin_database,
catalog_factory=lambda mapper: (
plugin_manager_module._plugin_catalog_factory(mapper)
),
import_preparer=lambda **kwargs: (
plugin_manager_module._legacy_plugin_import_preparer(**kwargs)
),
import_scanner=lambda **kwargs: (
plugin_manager_module._legacy_import_scanner(**kwargs)
),
auth_level=lambda: plugin_manager_module._site_auth_level_provider(),
remote_entry=host.get_plugin_remote_entry,
development=lambda: bool(
plugin_manager_module.get_runtime_setting('DEV')
),
logger=plugin_manager_module.logger,
set_default_target=_set_plugin_default_target,
clear_default_target=_clear_plugin_default_target,
runtime_declaration=_read_plugin_runtime_declaration,
),
tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS,
)
plugin_manager_module.configure_plugin_runtime_factory(build_test_plugin_runtime)
configure_plugin_runtime(
lambda: PluginManager(),
existing_provider=PluginManager.get_existing_instance,
)
configure_module_runtime(lambda: ModuleManager())
from app.application.site.health import SiteHealthService, configure_site_health_service
from app.application.site.query import SiteQueryService, configure_site_query_service
from app.application.workflow import (
WorkflowQueryService,
configure_workflow_execution,
configure_workflow_query,
configure_workflow_runtime,
)
from app.workflow import WorkflowManager
configure_workflow_runtime(lambda: WorkflowManager())
from app.application.agent import AgentDataContext
from app.application.agenttask import (
AgentTaskExecutionService,
configure_agent_task_execution,
)
from app.db.adapters.agent import (
SessionAgentTaskRepository,
TransactionalAgentTaskRepository,
TransactionalPluginDataRepository,
)
from app.db.adapters.download import TransactionalDownloadFailureRepository
from app.db.adapters.history.download import TransactionalDownloadHistoryRepository
from app.db.adapters.history.transfer import TransactionalTransferHistoryRepository
from app.db.adapters.mediaserver import TransactionalMediaServerRepository
from app.db.adapters.site import TransactionalSiteRepository
from app.db.adapters.subscription import TransactionalSubscriptionRepository
from app.db.adapters.transaction import TransactionalWriteRunner
from app.db.adapters.transfer.admission import TransactionalTransferAdmissionRepository
from app.db.adapters.transfer.execution import (
TransactionalTransferExecutionRepository,
)
from app.db.adapters.user import (
SqlAlchemyUserRepository,
TransactionalUserRepository,
)
from app.db.adapters.workflow import (
TransactionalWorkflowExecutionService,
TransactionalWorkflowQueryRepository,
)
from app.db.oper.agentchat import AgentChatOper
from app.db.oper.downloadhistory import DownloadHistoryOper
from app.db.oper.mediaserver import MediaServerOper
from app.db.oper.message import MessageOper
from app.db.oper.passkey import PassKeyOper
from app.db.oper.site import SiteOper
from app.db.oper.subscribe import SubscribeOper
from app.db.oper.subscribehistory import SubscribeHistoryOper
from app.db.oper.transferhistory import TransferHistoryOper
from app.db.oper.workflow import WorkflowOper
def create_sync_session() -> Session:
"""
为无显式会话的持久化测试入口创建独占同步会话。
:return: 新的隔离数据库会话,由事务执行器负责关闭
"""
return SessionFactory()
transaction_runner = TransactionalWriteRunner(
sync_session=create_sync_session,
async_session=async_session_scope,
)
configure_transaction_runners(
sync=transaction_runner.sync,
async_=transaction_runner.async_,
)
workflow_execution = TransactionalWorkflowExecutionService(SessionFactory)
configure_workflow_execution(workflow_execution)
configure_api_data_ports(
sync_session=get_db,
async_session=get_async_db,
repositories={
"download_history": DownloadHistoryOper,
"media_server": MediaServerOper,
"message": MessageOper,
"passkey": PassKeyOper,
"site": SiteOper,
"subscribe": SubscribeOper,
"subscribe_history": SubscribeHistoryOper,
"transfer_history": TransferHistoryOper,
"user": SqlAlchemyUserRepository,
"workflow": WorkflowOper,
},
standalone={
"passkey": PassKeyOper,
"system_config": SystemConfigOper,
"user": lambda: TransactionalUserRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
),
},
unit_of_work={
"async": SqlAlchemyAsyncUnitOfWork,
"sync": SqlAlchemyUnitOfWork,
},
)
def site_repository() -> TransactionalSiteRepository:
"""
按生产组合方式构造显式事务站点仓储。
:return: 使用隔离同步、异步会话工厂的站点仓储
"""
return TransactionalSiteRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
def user_repository() -> TransactionalUserRepository:
"""
按生产组合方式构造用户短会话仓储。
:return: 使用隔离同步、异步会话工厂的用户仓储
"""
return TransactionalUserRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
subscription_repository = TransactionalSubscriptionRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
download_history_repository = TransactionalDownloadHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
transfer_history_repository = TransactionalTransferHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
message_queue = MessageQueueManager(auto_start=False)
configure_chain_runtime_context_provider(
lambda: ChainRuntimeContext(
module_manager=ModuleManager(),
plugin_manager=PluginManager(),
event_manager=EventManager(),
message_oper=MessageOper(),
message_helper=MessageHelper(),
file_cache=FileCache(),
async_file_cache=AsyncFileCache(),
message_queue=message_queue,
module_dispatcher_factory=ModuleInvocationDispatcher,
site_repository=site_repository(),
subscription_repository=subscription_repository,
subscription_mutation_scope=subscription_mutation_scope,
sync_subscription_mutation_scope=sync_subscription_mutation_scope,
subscription_delete_scope=delete_subscribe_scope,
sync_subscription_delete_scope=sync_delete_subscribe_scope,
subscription_completion_scope=subscription_completion_scope,
rule_group_mutation_scope=partial(
rule_group_mutation_scope,
system_config.publish_many,
),
site_reference_mutation_scope=partial(
site_reference_mutation_scope,
system_config.publish_many,
),
download_history_repository=download_history_repository,
transfer_history_repository=transfer_history_repository,
transfer_admission_repository=TransactionalTransferAdmissionRepository(SessionFactory),
transfer_execution_repository=TransactionalTransferExecutionRepository(SessionFactory),
media_server_repository=TransactionalMediaServerRepository(SessionFactory),
download_failure_repository=TransactionalDownloadFailureRepository(SessionFactory),
user_repository=user_repository(),
configuration=build_chain_runtime_config(settings),
)
)
from app.startup.initializers.chain import init_chain_ports, reset_chain_ports
from app.startup.initializers.network import (
init_chain_network_ports,
reset_chain_network_ports,
)
init_chain_ports()
init_chain_network_ports()
configure_site_query_service(SiteQueryService(repository=site_repository()))
configure_site_health_service(SiteHealthService(repository=site_repository()))
configure_workflow_query(
WorkflowQueryService(
repository=TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
)
)
from app.db.adapters.subscription import (
TransactionalSubscriptionHistoryRepository,
)
agent_chat_persistence = AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
)
agent_chat_service = AgentChatService(repository=AgentChatOper())
agent_task_repository = TransactionalAgentTaskRepository(SessionFactory)
agent_data_context = AgentDataContext(
chat=agent_chat_service,
chat_persistence=agent_chat_persistence,
tasks=agent_task_repository,
users=user_repository(),
sites=site_repository(),
subscriptions=subscription_repository,
subscription_mutation_scope=subscription_mutation_scope,
subscription_delete_scope=delete_subscribe_scope,
async_rule_group_mutation_scope=partial(
async_rule_group_mutation_scope,
system_config.publish_many,
),
subscription_history=TransactionalSubscriptionHistoryRepository(
async_session=async_session_scope,
),
transfer_history=transfer_history_repository,
transfer_execution=TransactionalTransferExecutionRepository(SessionFactory),
download_history=download_history_repository,
plugin_data=TransactionalPluginDataRepository(async_session_scope),
)
configure_agent_task_execution(
AgentTaskExecutionService(
repository=SessionAgentTaskRepository,
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
)
)
configure_agent_chat_persistence(agent_chat_persistence)
configure_agent_chat_service(agent_chat_service)
from app.agent.tools.manager import moviepilot_tool_manager
from app.scheduler.facade import Scheduler
moviepilot_tool_manager.set_data_context(agent_data_context)
Scheduler().configure_agent_tasks(agent_task_repository)
from app.adapters.external.plugin.client import (
VERSION_BACKWARD_COMPATIBLE_FLAGS,
PluginMarketClient,
PluginMarketTransport,
PluginPackageSourceClient,
)
from app.adapters.system.plugin.dependency import PluginDependencyInstaller
from app.adapters.system.plugin.manifest import dependency_manifest_status
from app.adapters.system.plugin.package import PluginPackageManager
from app.runtime.extensions.plugin.system import (
PluginSystemServices,
configure_plugin_system,
reset_plugin_system,
)
market_transport = PluginMarketTransport()
configure_plugin_system(
PluginSystemServices(
market=PluginMarketClient(market_transport),
package=PluginPackageManager(
source=PluginPackageSourceClient(market_transport),
),
dependency=PluginDependencyInstaller(),
dependency_manifest_status=dependency_manifest_status,
compatible_flags=lambda flag: [flag] + VERSION_BACKWARD_COMPATIBLE_FLAGS.get(flag, []) if flag else [],
frozen=lambda: False,
install=lambda **_kwargs: (False, "测试环境未装配插件安装 Gateway"),
)
)
from app.agent.llm.gateway import register_llm_provider_runtime
from app.agent.llm.provider import LLMProviderManager
from app.agent.skills.registry import SkillHelper
from app.application.messaging.skill import register_skill_catalog_provider
register_skill_catalog_provider(lambda: SkillHelper())
register_llm_provider_runtime(lambda: LLMProviderManager())
yield
reset_chain_network_ports()
reset_chain_ports()
reset_plugin_system()
reset_plugin_runtime_factory()
class DbHarness:
"""
为隔离数据库测试记录新增行水位并在用例结束时回收。
同一 pytest 会话共用隔离库,只删除已登记表中主键大于初始水位的行。
不恢复原有行的修改或删除,也不回收未登记的表;这些操作需要测试另行隔离。
:ivar session: 供用例读写及清理使用的同步数据库会话
:ivar _watermarks: 已登记模型到初始最大主键的映射
"""
def __init__(self, session):
"""
绑定测试会话并创建空的表水位记录。
:param session: 由 db 夹具创建并负责关闭的同步会话
"""
self.session = session
self._watermarks = {}
def watermark(self, *models) -> None:
"""
登记表的初始最大主键,重复登记不改变水位。
必须在被测操作新增数据之前登记;已有水位保留首次值。
:param models: 需要回收新增行且提供 id 字段的模型类
"""
from sqlalchemy import func, select
for model in models:
if model in self._watermarks:
continue
current = self.session.execute(select(func.max(model.id))).scalar()
self._watermarks[model] = current or 0
def add(self, *rows):
"""
登记对应表水位后插入测试数据并提交事务。
同表中随后由被测代码新增的较大主键行也会纳入清理。
:param rows: 要插入的模型实例
:return: 单个输入返回该实例,多个或零个输入返回列表
"""
self.watermark(*{type(row) for row in rows})
for row in rows:
self.session.add(row)
self.session.commit()
return rows[0] if len(rows) == 1 else list(rows)
def run_async_session(
self,
operation: Callable[[AsyncSession], Awaitable[TResult]],
) -> TResult:
"""
在新事件循环的异步会话中执行被测操作。
调用 asyncio.run,不适用于当前线程已有运行中事件循环的场景;会话在作用域结束时关闭。
:param operation: 接收异步会话的协程回调,提交或回滚由回调负责
:return: 等待回调后的结果
"""
from app.db.session import async_session_scope
async def execute() -> TResult:
"""
打开异步会话并等待测试回调,不代替回调提交事务。
:return: 回调返回值
"""
async with async_session_scope() as session:
return await operation(session)
return asyncio.run(execute())
def cleanup(self) -> None:
"""
尽力按主键水位回收已登记表中的新增行。
先回滚残留事务,再按外键依赖的反向顺序逐表删除和提交;单表清理失败会回滚该事务,
继续后续表,避免覆盖测试原有断言结果。
"""
from sqlalchemy import delete
# 用例可能因约束冲突等原因让事务处于待回滚状态,此时任何语句都会被拒绝;
# 先回滚再清理,否则清理会整体失效、数据泄漏到后续用例
try:
self.session.rollback()
# 会话已不可用时仍继续尝试逐表清理,避免影响其他用例。
except Exception: # noqa: BLE001
pass
from app.db.base import Base
table_order = {table: index for index, table in enumerate(Base.metadata.sorted_tables)}
models = sorted(
self._watermarks,
key=lambda model: table_order.get(model.__table__, -1),
reverse=True,
)
for model in models:
mark = self._watermarks[model]
try:
self.session.execute(delete(model).where(model.id > mark))
self.session.commit()
# 清理失败不应掩盖用例本身的断言结果,回滚后继续其他表。
except Exception: # noqa: BLE001
self.session.rollback()
@pytest.fixture
def db():
"""
提供隔离数据库的同步测试载具,并在结束时清理新增行和关闭会话。
只有通过载具登记过水位的表会回收新增行;对旧行的修改或删除需由用例单独处理。
:yields: 绑定当前同步会话的 DbHarness
"""
from app.db.session import ScopedSession
session = ScopedSession()
harness = DbHarness(session)
try:
yield harness
finally:
harness.cleanup()
session.close()
@pytest.fixture
def frozen_now(monkeypatch):
"""
提供只冻结指定模块 time.time 的局部时钟替换工具。
其他时间函数仍委托标准库,便于验证时间窗口包含或排除边界的行为。
:param monkeypatch: 在用例结束时恢复模块 time 属性的 pytest 夹具
:return: freeze(module) 函数,安装冻结时钟并返回冻结的 Unix 秒数
"""
import time as real_time
class _FrozenClock:
"""
固定时间戳并将其他时间能力透传给标准库。
:ivar now: 构造时保存的 Unix 时间戳,单位为秒
"""
def __init__(self, now: float):
"""
保存本次冻结的时间点。
:param now: Unix 时间戳,单位为秒
"""
self.now = now
def time(self) -> float:
"""
读取固定的当前时间。
:return: 构造时记录的 Unix 秒数
"""
return self.now
def __getattr__(self, name):
"""
将未覆盖的时间属性委托给标准库 time。
:param name: 标准库时间属性或函数名
:return: 原始属性或函数,不改变其行为
"""
return getattr(real_time, name)
def freeze(module) -> float:
"""
以当前实际时间为基准冻结指定模块的时钟。
:param module: 使用 time.time 取当前时间的被测模块
:return: 冻结的 Unix 秒级时间戳
"""
clock = _FrozenClock(real_time.time())
monkeypatch.setattr(module, "time", clock)
return clock.now
return freeze
def _report_session_cleanup_error(session, name: str, err: Exception) -> None:
"""
记录资源收尾错误,并把原本成功的测试会话标为失败。
:param session: 可更新退出状态的 pytest 会话对象
:param name: 失败资源的诊断名称
:param err: 收尾过程中捕获的异常
"""
sys.stderr.write(f"\npytest session cleanup failed: {name}: {err!r}\n")
if session.exitstatus == 0:
session.exitstatus = 1
def pytest_sessionfinish(session, exitstatus):
"""
在会话结束时关闭按需创建的后台执行器、消息服务和日志资源。
各资源分别尝试关闭,失败时记录错误并继续;原测试成功时,收尾失败使会话退出状态变为失败。
:param session: 当前 pytest 会话,收尾失败时可修改其退出状态
:param exitstatus: pytest 原始退出状态,当前实现直接读取 session.exitstatus
"""
try:
from app.agent.tools.base import shutdown_blocking_executors
shutdown_blocking_executors(cancel_futures=True)
except Exception as err:
_report_session_cleanup_error(session, "agent blocking executors", err)
try:
from app.runtime.thread import ThreadHelper
helper = ThreadHelper.get_existing_instance()
if helper and helper.shutdown() is False:
raise RuntimeError("shared thread pool did not converge")
except Exception as err:
_report_session_cleanup_error(session, "thread helper", err)
try:
from app.application.messaging.message import stop_message
stop_message()
except Exception as err:
_report_session_cleanup_error(session, "message service", err)
try:
from app.runtime.log import LoggerManager
if LoggerManager.shutdown() is False:
raise RuntimeError("log writer did not converge")
except Exception as err:
_report_session_cleanup_error(session, "logger manager", err)
@pytest.fixture
def terminal_scope():
"""
为终端契约测试绑定固定宿主身份,并在退出时恢复原上下文。
:yields: 用户 terminal-test-owner、任务 terminal-test 和 test 类型组成的终端作用域
"""
from app.agent.terminal.ownership import TerminalScope, bind_terminal_scope
scope = TerminalScope(user_id="terminal-test-owner", task_id="terminal-test", kind="test")
with bind_terminal_scope(scope):
yield scope