refactor: unify database readiness lifecycle

This commit is contained in:
jxxghp
2026-08-21 20:07:07 +08:00
parent bb57b22975
commit dd1c4c3279
24 changed files with 592 additions and 268 deletions
+11 -5
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6080,
"edge_sha256": "57d091e93986ca0d8e8a93a300c3c6d0eb0c2bfe92fda747cffd829cc3c25cab",
"edge_count": 6084,
"edge_sha256": "0dbce22b56284b845591fd38771a96c937ec898db7e9d1aece1ec47ceff8de04",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -151,6 +151,8 @@
"app.adapters.system.rust -> app.runtime",
"app.adapters.system.rust -> app.runtime.config",
"app.adapters.system.rust -> app.runtime.log",
"app.adapters.web.health -> app.runtime",
"app.adapters.web.health -> app.runtime.health",
"app.adapters.web.security.access -> app.runtime",
"app.adapters.web.security.access -> app.runtime.cache",
"app.adapters.web.security.access -> app.runtime.config",
@@ -3608,6 +3610,7 @@
"app.domain.title -> app.schemas.types",
"app.factory -> app.adapters",
"app.factory -> app.adapters.web",
"app.factory -> app.adapters.web.health",
"app.factory -> app.adapters.web.plugin",
"app.factory -> app.adapters.web.plugin.routes",
"app.factory -> app.adapters.web.security",
@@ -3639,8 +3642,6 @@
"app.main -> app.runtime",
"app.main -> app.runtime.config",
"app.main -> app.runtime.topology",
"app.main -> app.startup",
"app.main -> app.startup.database_initializer",
"app.modules -> app.runtime",
"app.modules -> app.runtime.extensions",
"app.modules -> app.runtime.extensions.service_config",
@@ -5713,6 +5714,7 @@
"app.startup.database -> app.runtime",
"app.startup.database -> app.runtime.config",
"app.startup.database_initializer -> app.db",
"app.startup.database_initializer -> app.db.base",
"app.startup.database_initializer -> app.db.engine",
"app.startup.database_initializer -> app.db.models",
"app.startup.database_initializer -> app.runtime",
@@ -5750,12 +5752,14 @@
"app.startup.lifecycle -> app.db.engine",
"app.startup.lifecycle -> app.runtime",
"app.startup.lifecycle -> app.runtime.config",
"app.startup.lifecycle -> app.runtime.health",
"app.startup.lifecycle -> app.runtime.log",
"app.startup.lifecycle -> app.runtime.state",
"app.startup.lifecycle -> app.runtime.topology",
"app.startup.lifecycle -> app.startup",
"app.startup.lifecycle -> app.startup.cache_initializer",
"app.startup.lifecycle -> app.startup.command_initializer",
"app.startup.lifecycle -> app.startup.database_initializer",
"app.startup.lifecycle -> app.startup.domain_initializer",
"app.startup.lifecycle -> app.startup.lifecycle.components",
"app.startup.lifecycle -> app.startup.modules_initializer",
@@ -6097,7 +6101,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 754,
"module_count": 756,
"modules": [
"app",
"app.adapters",
@@ -6137,6 +6141,7 @@
"app.adapters.system.rust",
"app.adapters.system.stdio",
"app.adapters.web",
"app.adapters.web.health",
"app.adapters.web.plugin",
"app.adapters.web.plugin.routes",
"app.adapters.web.security",
@@ -6746,6 +6751,7 @@
"app.runtime.extensions.plugin_manager",
"app.runtime.extensions.service_config",
"app.runtime.gc",
"app.runtime.health",
"app.runtime.localization",
"app.runtime.log",
"app.runtime.managed_resources",
+162 -156
View File
@@ -1,41 +1,41 @@
{
"schema_version": 1,
"generated_at": "2026-08-21T11:45:07.622632+00:00",
"generated_at": "2026-08-21T12:02:30.246931+00:00",
"platform": "macOS-26.5.2-arm64-arm-64bit",
"python": "3.12.6",
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_module_count": 1809,
"max_ms": 1023.29,
"median_ms": 1018.356,
"min_ms": 992.011,
"loaded_module_count": 1810,
"max_ms": 987.312,
"median_ms": 984.741,
"min_ms": 983.917,
"samples_ms": [
1023.29,
1018.356,
992.011
987.312,
984.741,
983.917
]
},
"app.factory": {
"loaded_module_count": 1818,
"max_ms": 1058.352,
"median_ms": 1048.27,
"min_ms": 1045.441,
"loaded_module_count": 1820,
"max_ms": 1015.426,
"median_ms": 999.369,
"min_ms": 996.521,
"samples_ms": [
1058.352,
1048.27,
1045.441
996.521,
999.369,
1015.426
]
},
"app.main": {
"loaded_module_count": 1961,
"max_ms": 1316.051,
"median_ms": 1144.056,
"min_ms": 1138.523,
"loaded_module_count": 1844,
"max_ms": 1057.647,
"median_ms": 1037.08,
"min_ms": 1034.011,
"samples_ms": [
1138.523,
1316.051,
1144.056
1057.647,
1034.011,
1037.08
]
}
},
@@ -46,132 +46,24 @@
"samples": [
{
"mode": "normal",
"enabled_component_count": 14,
"startup_ms": 0.566,
"full_lifespan_ms": 0.67,
"stage_ms": {
"HTTP 基础能力": 0.077,
"领域依赖装配": 0.038,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.025,
"路由": 0.022,
"模块服务": 0.022,
"插件备份恢复": 0.022,
"插件": 0.021,
"定时器": 0.022,
"监控器": 0.022,
"待处理整理回放": 0.023,
"命令服务": 0.022,
"工作流": 0.022,
"插件同步与启动收尾": 0.035
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 14,
"startup_ms": 0.574,
"enabled_component_count": 15,
"startup_ms": 0.569,
"full_lifespan_ms": 0.669,
"stage_ms": {
"HTTP 基础能力": 0.08,
"领域依赖装配": 0.036,
"数据库准备": 0.08,
"HTTP 基础能力": 0.035,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.029,
"数据库连接预算": 0.027,
"路由": 0.027,
"模块服务": 0.023,
"插件备份恢复": 0.022,
"插件": 0.02,
"定时器": 0.02,
"监控器": 0.023,
"待处理整理回放": 0.025,
"命令服务": 0.023,
"工作流": 0.023,
"插件同步与启动收尾": 0.03
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 14,
"startup_ms": 0.571,
"full_lifespan_ms": 0.678,
"stage_ms": {
"HTTP 基础能力": 0.077,
"领域依赖装配": 0.036,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.029,
"路由": 0.025,
"模块服务": 0.022,
"插件备份恢复": 0.022,
"插件": 0.023,
"定时器": 0.023,
"数据库连接预算": 0.025,
"路由": 0.024,
"模块服务": 0.024,
"插件备份恢复": 0.023,
"插件": 0.021,
"定时器": 0.019,
"监控器": 0.022,
"待处理整理回放": 0.021,
"命令服务": 0.023,
"工作流": 0.022,
"插件同步与启动收尾": 0.036
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
}
],
"median_startup_ms": 0.571,
"median_full_lifespan_ms": 0.67,
"enabled_component_count": 14
},
"safe": {
"samples": [
{
"mode": "safe",
"enabled_component_count": 6,
"startup_ms": 0.419,
"full_lifespan_ms": 0.523,
"stage_ms": {
"HTTP 基础能力": 0.082,
"领域依赖装配": 0.04,
"数据库引擎预热": 0.031,
"数据库连接预算": 0.029,
"路由": 0.025,
"模块服务": 0.022,
"插件同步与启动收尾": 0.037
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 6,
"startup_ms": 0.488,
"full_lifespan_ms": 0.582,
"stage_ms": {
"HTTP 基础能力": 0.078,
"领域依赖装配": 0.036,
"数据库引擎预热": 0.03,
"数据库连接预算": 0.027,
"路由": 0.024,
"模块服务": 0.022,
"命令服务": 0.019,
"工作流": 0.021,
"插件同步与启动收尾": 0.032
},
"threads_before": 2,
@@ -183,18 +75,56 @@
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 6,
"startup_ms": 0.41,
"full_lifespan_ms": 0.538,
"mode": "normal",
"enabled_component_count": 15,
"startup_ms": 0.548,
"full_lifespan_ms": 0.641,
"stage_ms": {
"HTTP 基础能力": 0.076,
"领域依赖装配": 0.034,
"数据库准备": 0.071,
"HTTP 基础能力": 0.033,
"领域依赖装配": 0.032,
"数据库引擎预热": 0.028,
"数据库连接预算": 0.026,
"路由": 0.025,
"模块服务": 0.024,
"插件同步与启动收尾": 0.06
"数据库连接预算": 0.025,
"路由": 0.022,
"模块服务": 0.023,
"插件备份恢复": 0.019,
"插件": 0.02,
"定时器": 0.022,
"监控器": 0.024,
"待处理整理回放": 0.019,
"命令服务": 0.022,
"工作流": 0.022,
"插件同步与启动收尾": 0.028
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 15,
"startup_ms": 0.687,
"full_lifespan_ms": 0.795,
"stage_ms": {
"数据库准备": 0.083,
"HTTP 基础能力": 0.035,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.033,
"数据库连接预算": 0.027,
"路由": 0.021,
"模块服务": 0.019,
"插件备份恢复": 0.064,
"插件": 0.032,
"定时器": 0.025,
"监控器": 0.028,
"待处理整理回放": 0.025,
"命令服务": 0.024,
"工作流": 0.024,
"插件同步与启动收尾": 0.037
},
"threads_before": 2,
"threads_started": 2,
@@ -205,9 +135,85 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.419,
"median_full_lifespan_ms": 0.538,
"enabled_component_count": 6
"median_startup_ms": 0.569,
"median_full_lifespan_ms": 0.669,
"enabled_component_count": 15
},
"safe": {
"samples": [
{
"mode": "safe",
"enabled_component_count": 7,
"startup_ms": 0.408,
"full_lifespan_ms": 0.531,
"stage_ms": {
"数据库准备": 0.077,
"HTTP 基础能力": 0.035,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.024,
"路由": 0.024,
"模块服务": 0.024,
"插件同步与启动收尾": 0.057
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 7,
"startup_ms": 0.416,
"full_lifespan_ms": 0.534,
"stage_ms": {
"数据库准备": 0.079,
"HTTP 基础能力": 0.036,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.027,
"数据库连接预算": 0.026,
"路由": 0.026,
"模块服务": 0.021,
"插件同步与启动收尾": 0.054
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "safe",
"enabled_component_count": 7,
"startup_ms": 0.397,
"full_lifespan_ms": 0.52,
"stage_ms": {
"数据库准备": 0.07,
"HTTP 基础能力": 0.033,
"领域依赖装配": 0.026,
"数据库引擎预热": 0.027,
"数据库连接预算": 0.029,
"路由": 0.024,
"模块服务": 0.022,
"插件同步与启动收尾": 0.051
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 2,
"tasks_after": 1,
"database_connections_started": 0
}
],
"median_startup_ms": 0.408,
"median_full_lifespan_ms": 0.531,
"enabled_component_count": 7
}
}
}
+57
View File
@@ -7,11 +7,14 @@ import uuid
import pytest
from alembic.util import CommandError
from fastapi import FastAPI
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, inspect, text
from sqlalchemy.engine.url import make_url
from app.startup import database_initializer as db_init
from app.startup import database as startup_database
from app.startup import lifecycle
from app.runtime.health import get_application_health
LOCAL_SETUP_PATH = (
@@ -272,6 +275,60 @@ def test_migration_lineage_wraps_unknown_revision() -> None:
)
def test_verify_database_revision_requires_current_head(monkeypatch) -> None:
"""readiness 的数据库校验必须拒绝升级后仍未到 head 的状态。"""
engine = object()
config = object()
monkeypatch.setattr(db_init, "get_engine", lambda: engine)
monkeypatch.setattr(db_init, "_build_alembic_config", lambda _: config)
monkeypatch.setattr(
db_init,
"_migration_state",
lambda *_: (True, ("old",), ("head",)),
)
with pytest.raises(RuntimeError, match="仍未到达当前 head"):
db_init.verify_database_revision()
def test_verify_database_revision_accepts_current_head(monkeypatch) -> None:
"""活动 revision 与唯一目标 head 一致时允许发布数据库就绪。"""
engine = object()
config = object()
monkeypatch.setattr(db_init, "get_engine", lambda: engine)
monkeypatch.setattr(db_init, "_build_alembic_config", lambda _: config)
monkeypatch.setattr(
db_init,
"_migration_state",
lambda *_: (True, ("head",), ("head",)),
)
db_init.verify_database_revision()
def test_lifecycle_database_component_marks_ready_after_head_check(
monkeypatch,
) -> None:
"""数据库组件必须按迁移、head 校验、发布状态的顺序执行。"""
app = FastAPI()
calls: list[str] = []
monkeypatch.setattr(
db_init,
"prepare_database",
lambda: calls.append("prepare"),
)
monkeypatch.setattr(
db_init,
"verify_database_revision",
lambda: calls.append("verify"),
)
lifecycle.prepare_database_component(app)
assert calls == ["prepare", "verify"]
assert get_application_health(app).database_ready is True
def test_prepare_database_creates_real_sqlite_restore_point_before_upgrade(
tmp_path: Path,
monkeypatch,
+2
View File
@@ -50,6 +50,8 @@ def test_dockerfile_control_bundle_build_checks_fail_closed() -> None:
)
assert 'ENTRYPOINT [ "/usr/bin/tini", "-g", "--", "/entrypoint.sh" ]' in dockerfile
assert "CMD /usr/bin/curl -fsS" in dockerfile
assert '"http://127.0.0.1:${PORT:-3001}/health/ready"' in dockerfile
assert "system/global?token=moviepilot" not in dockerfile
assert (
'for control_script in /bundle/control/*.sh; do bash -n "${control_script}" || exit 1; done'
in dockerfile
+1 -1
View File
@@ -552,7 +552,7 @@ def test_backend_ready_log_uses_configured_ports(tmp_path: Path) -> None:
)
assert curl_log.read_text(encoding="utf-8") == (
"-fsS --max-time 2 http://127.0.0.1:4321/api/v1/system/global?token=moviepilot\n"
"-fsS --max-time 2 http://127.0.0.1:4321/health/ready\n"
)
assert "MoviePilot Web 已可访问" in output
assert "后端就绪耗时" in output
+83
View File
@@ -0,0 +1,83 @@
"""公开 liveness/readiness 探针契约测试。"""
from pathlib import Path
from fastapi.testclient import TestClient
from app.factory import create_app
from app.runtime.health import ReadinessPhase, get_application_health
PROJECT_ROOT = Path(__file__).parents[1]
def test_liveness_is_public_minimal_and_independent_from_readiness() -> None:
"""存活探针不访问依赖,即使启动失败也只报告事件循环仍可响应。"""
app = create_app()
get_application_health(app).mark_failed()
response = TestClient(app).get("/health/live")
assert response.status_code == 200
assert response.json() == {"status": "alive"}
def test_readiness_is_unavailable_before_database_and_lifecycle_complete() -> None:
"""尚未进入 lifespan 或启动失败时不得让编排器接入流量。"""
app = create_app()
client = TestClient(app)
response = client.get("/health/ready")
assert response.status_code == 503
assert response.json() == {"status": "not_ready"}
def test_readiness_only_exposes_ready_after_database_and_lifecycle() -> None:
"""数据库 head 校验和完整启动都成功后才返回最小 ready 状态。"""
app = create_app()
health = get_application_health(app)
health.mark_database_ready()
health.mark_ready()
response = TestClient(app).get("/health/ready")
assert response.status_code == 200
assert response.json() == {"status": "ready"}
health.mark_stopping()
stopped = TestClient(app).get("/health/ready")
assert stopped.status_code == 503
assert stopped.json() == {"status": "not_ready"}
assert health.phase is ReadinessPhase.STOPPING
def test_health_routes_are_public_but_hidden_from_business_openapi() -> None:
"""基础设施探针无需鉴权,但不扩大业务 OpenAPI 的公开契约。"""
app = create_app()
client = TestClient(app)
assert client.get("/health/live").status_code == 200
schema = client.get("/api/v1/openapi.json").json()
assert "/health/live" not in schema["paths"]
assert "/health/ready" not in schema["paths"]
def test_operational_consumers_use_readiness_probe() -> None:
"""容器、本地前端和性能工具不得再借业务接口判断启动完成。"""
paths = (
"docker/Dockerfile",
"docker/entrypoint.sh",
"scripts/local_setup.py",
"scripts/perf/moviepilot_docker_ab.py",
)
contents = [
(PROJECT_ROOT / relative_path).read_text(encoding="utf-8")
for relative_path in paths
]
assert all("/health/ready" in content for content in contents)
assert all(
"system/global?token=moviepilot" not in content
for content in contents
)
+29 -23
View File
@@ -41,6 +41,16 @@ def _patch_lifespan(monkeypatch, *, failing_step: str | None = None) -> dict:
# 自洽了,而且额度核算还会去连库。
for name in ("get_engine", "get_global_async_engine", "check_connection_budget"):
monkeypatch.setattr(lifecycle, name, MagicMock())
database_prepare = MagicMock(
side_effect=lambda app: lifecycle.get_application_health(
app
).mark_database_ready()
)
monkeypatch.setattr(
lifecycle,
"prepare_database_component",
database_prepare,
)
system_chain = MagicMock()
monkeypatch.setattr(lifecycle, "SystemChain", MagicMock(return_value=system_chain))
@@ -125,6 +135,7 @@ def test_lifespan_normal_mode_starts_full_runtime(monkeypatch):
asyncio.run(run_lifespan())
lifecycle.init_modules.assert_awaited_once_with()
lifecycle.prepare_database_component.assert_called_once()
lifecycle.configure_plugin_services.assert_called_once_with()
for name in (
"init_plugins",
@@ -195,6 +206,7 @@ def test_lifespan_safe_mode_skips_optional_runtime(monkeypatch):
asyncio.run(run_lifespan())
lifecycle.init_modules.assert_awaited_once_with()
lifecycle.prepare_database_component.assert_called_once()
for name in (
"init_plugins",
"init_scheduler",
@@ -241,6 +253,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
safe_names = {item["name"] for item in safe}
assert normal_start == [
"数据库准备",
"HTTP 基础能力",
"领域依赖装配",
"数据库引擎预热",
@@ -266,6 +279,7 @@ def test_lifecycle_manifest_declares_normal_and_safe_mode_order() -> None:
"HTTP 基础能力",
]
assert safe_names == {
"数据库准备",
"HTTP 基础能力",
"领域依赖装配",
"数据库引擎预热",
@@ -324,11 +338,8 @@ def test_lifespan_creates_global_async_engine_at_startup(monkeypatch):
def test_lifespan_creates_sync_engine_at_startup(monkeypatch):
"""启动期也必须把同步引擎建出来一次,把首次创建钉在单线程期
「init_db() 会在启动期单线程预热同步引擎」这个前提只对 run_application() 入口成立。
外部 supervisor 直挂 ASGI app`gunicorn -k uvicorn.workers.UvicornWorker
app.factory:app`、`uvicorn app.main:app`)时 run_application() 不执行、init_db() 也就
不执行,同步引擎的首次创建退到运行期——而那时 init_scheduler() / init_monitor() 已经
放出上百个线程,引擎构建里那段 PRAGMA journal_mode 会让它们一起堵在创建锁上。
数据库准备已统一进入 lifespan,所有受支持 ASGI 入口都会先由 init_db() 创建同步引擎;
随后的显式预热仍用于冻结顺序契约,确保同步/异步引擎都早于 Router、Module 和后台线程。
"""
_patch_lifespan(monkeypatch)
created = []
@@ -430,11 +441,6 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
lambda *_args: calls.append("signal"),
)
monkeypatch.setattr(main, "start_tray", lambda: calls.append("tray"))
monkeypatch.setattr(
main,
"prepare_database",
lambda: calls.append("prepare_database"),
)
monkeypatch.setattr(main, "run_api_server", lambda: calls.append("server"))
main.run_application()
@@ -444,7 +450,6 @@ def test_application_preserves_stop_requested_before_startup(monkeypatch):
"signal",
"signal",
"tray",
"prepare_database",
"server",
]
@@ -456,26 +461,27 @@ def test_asgi_and_main_entrypoints_share_the_same_app_instance():
assert main.app is factory.app
def test_application_does_not_start_server_after_migration_failure(monkeypatch):
"""数据库迁移失败时不得启动 API 服务"""
from app import main
def test_lifespan_does_not_yield_after_migration_failure(monkeypatch):
"""数据库迁移失败时 lifespan 必须 fail-fast 且不得发布 ready"""
migration_error = RuntimeError("migration failed")
server_run = MagicMock()
monkeypatch.setattr(main.signal, "signal", MagicMock())
monkeypatch.setattr(main, "start_tray", MagicMock())
_patch_lifespan(monkeypatch)
monkeypatch.setattr(
main,
"prepare_database",
lifecycle,
"prepare_database_component",
MagicMock(side_effect=migration_error),
)
monkeypatch.setattr(main, "run_api_server", server_run)
app = FastAPI()
async def run_lifespan():
async with lifecycle.lifespan(app):
pytest.fail("数据库迁移失败后不应进入服务阶段")
with pytest.raises(RuntimeError) as raised:
main.run_application()
asyncio.run(run_lifespan())
assert raised.value is migration_error
server_run.assert_not_called()
assert app.state.moviepilot_health.is_ready is False
assert app.state.moviepilot_health.phase.value == "failed"
def test_uvicorn_preserves_stop_requested_before_serve(monkeypatch):
-3
View File
@@ -56,11 +56,9 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch):
monkeypatch.setattr(main.settings, "MOVIEPILOT_SAFE_MODE", False)
signal_handler = MagicMock()
start_tray = MagicMock()
prepare_database = MagicMock()
server_run = MagicMock()
monkeypatch.setattr(main.signal, "signal", signal_handler)
monkeypatch.setattr(main, "start_tray", start_tray)
monkeypatch.setattr(main, "prepare_database", prepare_database)
monkeypatch.setattr(main, "run_api_server", server_run)
with pytest.raises(UnsupportedProcessTopologyError):
@@ -68,7 +66,6 @@ def test_main_rejects_topology_before_startup_side_effects(monkeypatch):
signal_handler.assert_not_called()
start_tray.assert_not_called()
prepare_database.assert_not_called()
server_run.assert_not_called()
+11
View File
@@ -8,6 +8,7 @@ import pytest
from app import factory, main
from app.runtime.topology import UnsupportedProcessTopologyError
from app.startup import lifecycle
PROJECT_ROOT = Path(__file__).parents[1]
@@ -124,3 +125,13 @@ def test_local_launcher_keeps_module_entrypoint():
)
assert 'exec "$VENV_PYTHON" -m app.main' in script
def test_supported_asgi_entries_share_database_lifecycle() -> None:
"""主程序、factory 与 TestClient 应通过同一 lifespan 准备数据库。"""
created = factory.create_app()
assert factory.app.router.lifespan_context is lifecycle.lifespan
assert main.app.router.lifespan_context is lifecycle.lifespan
assert created.router.lifespan_context is lifecycle.lifespan
assert "prepare_database" not in main.run_application.__code__.co_names