refactor: close transactional boundary debt batch

This commit is contained in:
jxxghp
2026-08-28 10:36:12 +08:00
parent 3f8d5990e7
commit aa8751f775
105 changed files with 6507 additions and 1691 deletions
+59 -49
View File
@@ -3,6 +3,7 @@
引导与网络守卫均复用 ``app/testing`` 的共享 harness(与插件仓 conftest 同源),
引导逻辑只在 ``app/testing`` 维护一处。
"""
import asyncio
import sys
from collections.abc import Awaitable, Callable
@@ -124,8 +125,8 @@ def configure_plugin_system_services():
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.oper.userconfig import UserConfigOper
from app.db.session import (
SessionFactory,
async_session_scope,
@@ -159,10 +160,10 @@ def configure_plugin_system_services():
configure_token_runtime_config(lambda: build_token_runtime_config(settings))
database_executor = _TestDatabaseExecutor()
system_config = SystemConfigOper()
user_config = UserConfigOper()
user_config = TransactionalUserConfigurationRepository(SessionFactory)
with SessionFactory() as session:
system_config.load_snapshot(session)
user_config.load_snapshot(session)
user_config.load_snapshot()
configure_system_config(
SystemConfigService(
repository=system_config,
@@ -201,6 +202,7 @@ def configure_plugin_system_services():
from app.runtime.extensions.module_manager import ModuleManager
from app.runtime.extensions.plugin_manager import PluginManager
from app.runtime.extensions.service_config import ServiceConfigHelper
configure_service_directory(
configs=ServiceConfigHelper.get_configs,
modules=lambda module_type: ModuleManager().get_running_type_modules(module_type),
@@ -216,6 +218,7 @@ def configure_plugin_system_services():
configure_workflow_runtime,
)
from app.workflow import WorkflowManager
configure_workflow_runtime(lambda: WorkflowManager())
from app.application.agentdata import configure_agent_data_ports
from app.application.agenttask import (
@@ -320,42 +323,39 @@ def configure_plugin_system_services():
subscribe=lambda: SubscribeOper(),
download_history=lambda: DownloadHistoryOper(),
transfer_history=lambda: TransferHistoryOper(),
transfer_pending=lambda: TransactionalTransferAdmissionRepository(
SessionFactory
),
transfer_execution=lambda: TransactionalTransferExecutionRepository(
SessionFactory
),
transfer_pending=lambda: TransactionalTransferAdmissionRepository(SessionFactory),
transfer_execution=lambda: TransactionalTransferExecutionRepository(SessionFactory),
media_server=lambda: TransactionalMediaServerRepository(SessionFactory),
download_failure=lambda: TransactionalDownloadFailureRepository(
SessionFactory
),
download_failure=lambda: TransactionalDownloadFailureRepository(SessionFactory),
user=user_repository,
)
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_factory=lambda callback: MessageQueueManager(
send_callback=callback
),
module_dispatcher_factory=ModuleInvocationDispatcher,
configuration=build_chain_runtime_config(settings),
))
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_factory=lambda callback: MessageQueueManager(send_callback=callback),
module_dispatcher_factory=ModuleInvocationDispatcher,
configuration=build_chain_runtime_config(settings),
)
)
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,
configure_workflow_query(
WorkflowQueryService(
repository=TransactionalWorkflowQueryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
)
))
)
from app.db.oper.agenttask import AgentTaskOper
from app.db.oper.plugindata import PluginDataOper
configure_agent_data_ports(
agent_chat=lambda: AgentChatOper(),
agent_task=lambda: AgentTaskOper(),
@@ -367,11 +367,13 @@ def configure_plugin_system_services():
download_history=lambda: DownloadHistoryOper(),
plugin_data=lambda: PluginDataOper(),
)
configure_agent_task_execution(AgentTaskExecutionService(
repository=lambda session: AgentTaskOper(session),
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
))
configure_agent_task_execution(
AgentTaskExecutionService(
repository=lambda session: AgentTaskOper(session),
async_executor=database_executor,
sync_transaction=transaction_runner.sync,
)
)
configure_agent_chat_persistence(
AgentChatPersistenceService(
repository=lambda session: AgentChatOper(session),
@@ -395,18 +397,17 @@ def configure_plugin_system_services():
)
helper = PluginHelper()
configure_plugin_system(PluginSystemServices(
market=PluginMarketClient(helper),
package=PluginPackageManager(helper),
dependency=PluginDependencyInstaller(helper),
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"),
))
configure_plugin_system(
PluginSystemServices(
market=PluginMarketClient(helper),
package=PluginPackageManager(helper),
dependency=PluginDependencyInstaller(helper),
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
@@ -484,7 +485,16 @@ class DbHarness:
except Exception: # noqa: BLE001 会话已不可用时也要继续尝试清理
pass
for model, mark in self._watermarks.items():
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()
+3 -3
View File
@@ -1,8 +1,8 @@
{
"application": {
"covered_lines": 10195,
"percent": 79.02,
"statements": 12902
"covered_lines": 10432,
"percent": 79.39,
"statements": 13141
},
"domain": {
"covered_lines": 3392,
+49 -25
View File
@@ -14,9 +14,9 @@
"workflow_to_db": []
},
"direct_adapter_imports": {
"count": 28,
"count": 27,
"counts_by_source_root": {
"app.application": 15,
"app.application": 14,
"app.chain": 13
},
"edges": [
@@ -68,10 +68,6 @@
"source": "app.application.security.cookie",
"target": "app.adapters.network.http"
},
{
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis"
},
{
"source": "app.application.torrent",
"target": "app.adapters.network.http"
@@ -143,7 +139,7 @@
],
"target_root": "app.adapters"
},
"source_count": 18,
"source_count": 17,
"sources": [
"app.application.backup",
"app.application.directory",
@@ -152,7 +148,6 @@
"app.application.rss",
"app.application.rules",
"app.application.security.cookie",
"app.application.security.passkey",
"app.application.torrent",
"app.application.transfer.workflow",
"app.chain._recognition",
@@ -164,9 +159,8 @@
"app.chain.subscribe",
"app.chain.system"
],
"target_count": 11,
"target_count": 10,
"targets": [
"app.adapters.cache.redis",
"app.adapters.external.cookiecloud",
"app.adapters.external.ocr",
"app.adapters.external.server",
@@ -197,7 +191,7 @@
"from:redis.asyncio.Redis as Redis",
"import:redis as redis"
],
"fingerprint": "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7",
"fingerprint": "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81",
"kind": "network_sdk",
"source": "app.adapters.cache.redis",
"target": "redis",
@@ -226,6 +220,7 @@
"RedisHelper.clear|call:pipeline",
"RedisHelper.clear|call:scan_iter",
"RedisHelper.close|call:close",
"RedisHelper.consume|call:getdel",
"RedisHelper.delete|call:delete",
"RedisHelper.exists|call:exists",
"RedisHelper.get|call:get",
@@ -233,10 +228,9 @@
"RedisHelper.items|call:get",
"RedisHelper.items|call:scan_iter",
"RedisHelper.items|call:scan_iter",
"RedisHelper.pop|call:getdel",
"RedisHelper.set_memory_limit|call:config_set",
"RedisHelper.set_memory_limit|call:config_set",
"RedisHelper.set|call:set"
"RedisHelper.store|call:set"
]
},
{
@@ -1441,8 +1435,8 @@
"runtime_only": true
}
},
"edge_count": 6979,
"edge_sha256": "0fbef3f16d1475a40988a9fedbeb9a9ff67f49d0e3cd8033280b40a399a92d51",
"edge_count": 7006,
"edge_sha256": "74169f74212541ac2c1f990578f2e1d67f084944c08f1894c5fc1bff1d9016bd",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -2360,11 +2354,13 @@
"app.agent.tools.impl.query_download_tasks -> app.agent.tools.tags",
"app.agent.tools.impl.query_download_tasks -> app.application",
"app.agent.tools.impl.query_download_tasks -> app.application.agentdata",
"app.agent.tools.impl.query_download_tasks -> app.application.history",
"app.agent.tools.impl.query_download_tasks -> app.chain",
"app.agent.tools.impl.query_download_tasks -> app.chain.download",
"app.agent.tools.impl.query_download_tasks -> app.runtime",
"app.agent.tools.impl.query_download_tasks -> app.runtime.log",
"app.agent.tools.impl.query_download_tasks -> app.schemas",
"app.agent.tools.impl.query_download_tasks -> app.schemas.common",
"app.agent.tools.impl.query_download_tasks -> app.schemas.transfer",
"app.agent.tools.impl.query_download_tasks -> app.schemas.types",
"app.agent.tools.impl.query_downloaders -> app.agent",
@@ -3006,6 +3002,7 @@
"app.api.dependencies.auth -> app.application.security.auth",
"app.api.dependencies.auth -> app.application.security.passkey",
"app.api.dependencies.auth -> app.application.security.user",
"app.api.dependencies.auth -> app.application.security.userconfig",
"app.api.dependencies.auth -> app.schemas",
"app.api.dependencies.auth -> app.schemas.token",
"app.api.dependencies.auth -> app.startup",
@@ -3958,6 +3955,7 @@
"app.api.servcookie -> app.schemas",
"app.api.servcookie -> app.schemas.servcookie",
"app.application.agentdata -> app.application",
"app.application.agentdata -> app.application.history",
"app.application.agentdata -> app.application.security",
"app.application.agentdata -> app.application.security.user",
"app.application.agenttask -> app.application",
@@ -3989,6 +3987,7 @@
"app.application.chain.data -> app.application",
"app.application.chain.data -> app.application.download",
"app.application.chain.data -> app.application.download.failures",
"app.application.chain.data -> app.application.history",
"app.application.chain.data -> app.application.mediaserver",
"app.application.chain.data -> app.application.security",
"app.application.chain.data -> app.application.security.user",
@@ -4035,6 +4034,8 @@
"app.application.download.selection -> app.schemas",
"app.application.download.selection -> app.schemas.media",
"app.application.download.selection -> app.schemas.mediaserver",
"app.application.download.tasks -> app.application",
"app.application.download.tasks -> app.application.history",
"app.application.download.tasks -> app.schemas",
"app.application.download.tasks -> app.schemas.transfer",
"app.application.download.tasks -> app.schemas.types",
@@ -4067,6 +4068,7 @@
"app.application.history -> app.runtime.cache",
"app.application.history -> app.runtime.log",
"app.application.history -> app.schemas",
"app.application.history -> app.schemas.common",
"app.application.history -> app.schemas.history",
"app.application.history -> app.schemas.media",
"app.application.history -> app.schemas.transfer",
@@ -4328,13 +4330,9 @@
"app.application.security.cookie -> app.foundation.url",
"app.application.security.cookie -> app.runtime",
"app.application.security.cookie -> app.runtime.log",
"app.application.security.passkey -> app.adapters",
"app.application.security.passkey -> app.adapters.cache",
"app.application.security.passkey -> app.adapters.cache.redis",
"app.application.security.passkey -> app.application",
"app.application.security.passkey -> app.application.configuration",
"app.application.security.passkey -> app.runtime",
"app.application.security.passkey -> app.runtime.cache",
"app.application.security.passkey -> app.runtime.log",
"app.application.security.token -> app.application",
"app.application.security.token -> app.application.configuration",
@@ -4351,6 +4349,9 @@
"app.application.security.url -> app.runtime.log",
"app.application.security.userconfig -> app.application",
"app.application.security.userconfig -> app.application.database",
"app.application.security.userconfig -> app.schemas",
"app.application.security.userconfig -> app.schemas.common",
"app.application.security.userconfig -> app.schemas.types",
"app.application.servarr -> app.schemas",
"app.application.servarr -> app.schemas.types",
"app.application.server.report -> app.schemas",
@@ -4467,6 +4468,7 @@
"app.application.transfer.workflow -> app.adapters.system",
"app.application.transfer.workflow -> app.adapters.system.host",
"app.application.transfer.workflow -> app.application",
"app.application.transfer.workflow -> app.application.history",
"app.application.transfer.workflow -> app.application.transfer",
"app.application.transfer.workflow -> app.application.transfer.execution",
"app.application.transfer.workflow -> app.domain",
@@ -4482,7 +4484,6 @@
"app.application.transfer.workflow -> app.schemas",
"app.application.transfer.workflow -> app.schemas.context",
"app.application.transfer.workflow -> app.schemas.file",
"app.application.transfer.workflow -> app.schemas.history",
"app.application.transfer.workflow -> app.schemas.media",
"app.application.transfer.workflow -> app.schemas.music",
"app.application.transfer.workflow -> app.schemas.system",
@@ -4534,6 +4535,7 @@
"app.chain._messaging -> app.foundation",
"app.chain._messaging -> app.foundation.identity",
"app.chain._messaging -> app.runtime",
"app.chain._messaging -> app.runtime.correlation",
"app.chain._messaging -> app.runtime.log",
"app.chain._messaging -> app.schemas",
"app.chain._messaging -> app.schemas.message",
@@ -4612,7 +4614,6 @@
"app.chain._transfer -> app.runtime.log",
"app.chain._transfer -> app.runtime.tasks",
"app.chain._transfer -> app.schemas",
"app.chain._transfer -> app.schemas.history",
"app.chain._transfer -> app.schemas.message",
"app.chain._transfer -> app.schemas.tmdb",
"app.chain._transfer -> app.schemas.transfer",
@@ -4655,6 +4656,7 @@
"app.chain.download -> app.application.download.failures",
"app.chain.download -> app.application.download.selection",
"app.chain.download -> app.application.download.tasks",
"app.chain.download -> app.application.history",
"app.chain.download -> app.application.torrent",
"app.chain.download -> app.chain",
"app.chain.download -> app.chain.media",
@@ -5148,6 +5150,13 @@
"app.db.adapters.chain -> app.db.oper.transferpending",
"app.db.adapters.chain -> app.db.oper.transfersettlementreceipt",
"app.db.adapters.chain -> app.db.uow",
"app.db.adapters.configuration -> app.db",
"app.db.adapters.configuration -> app.db.oper",
"app.db.adapters.configuration -> app.db.oper.userconfig",
"app.db.adapters.configuration -> app.db.uow",
"app.db.adapters.configuration -> app.schemas",
"app.db.adapters.configuration -> app.schemas.common",
"app.db.adapters.configuration -> app.schemas.types",
"app.db.adapters.download -> app.application",
"app.db.adapters.download -> app.application.download",
"app.db.adapters.download -> app.application.download.failures",
@@ -5155,6 +5164,15 @@
"app.db.adapters.download -> app.db.oper",
"app.db.adapters.download -> app.db.oper.downloadfailure",
"app.db.adapters.download -> app.db.uow",
"app.db.adapters.history.download -> app.application",
"app.db.adapters.history.download -> app.application.history",
"app.db.adapters.history.download -> app.db",
"app.db.adapters.history.download -> app.db.oper",
"app.db.adapters.history.download -> app.db.oper.downloadhistory",
"app.db.adapters.history.download -> app.db.uow",
"app.db.adapters.history.download -> app.schemas",
"app.db.adapters.history.download -> app.schemas.media",
"app.db.adapters.history.download -> app.schemas.types",
"app.db.adapters.mediaserver -> app.application",
"app.db.adapters.mediaserver -> app.application.mediaserver",
"app.db.adapters.mediaserver -> app.db",
@@ -5203,6 +5221,7 @@
"app.db.adapters.site -> app.db.oper.site",
"app.db.adapters.site -> app.db.uow",
"app.db.adapters.subscription -> app.application",
"app.db.adapters.subscription -> app.application.outbox",
"app.db.adapters.subscription -> app.application.subscription",
"app.db.adapters.subscription -> app.application.subscription.write",
"app.db.adapters.subscription -> app.db",
@@ -5490,6 +5509,7 @@
"app.db.oper.userconfig -> app.foundation",
"app.db.oper.userconfig -> app.foundation.singleton",
"app.db.oper.userconfig -> app.schemas",
"app.db.oper.userconfig -> app.schemas.common",
"app.db.oper.userconfig -> app.schemas.types",
"app.db.oper.workflow -> app.db",
"app.db.oper.workflow -> app.db.base",
@@ -8053,7 +8073,10 @@
"app.startup.initializers.modules -> app.db",
"app.startup.initializers.modules -> app.db.adapters",
"app.startup.initializers.modules -> app.db.adapters.chain",
"app.startup.initializers.modules -> app.db.adapters.configuration",
"app.startup.initializers.modules -> app.db.adapters.download",
"app.startup.initializers.modules -> app.db.adapters.history",
"app.startup.initializers.modules -> app.db.adapters.history.download",
"app.startup.initializers.modules -> app.db.adapters.mediaserver",
"app.startup.initializers.modules -> app.db.adapters.outbox",
"app.startup.initializers.modules -> app.db.adapters.pluginidentity",
@@ -8070,7 +8093,6 @@
"app.startup.initializers.modules -> app.db.oper",
"app.startup.initializers.modules -> app.db.oper.agentchat",
"app.startup.initializers.modules -> app.db.oper.agenttask",
"app.startup.initializers.modules -> app.db.oper.downloadhistory",
"app.startup.initializers.modules -> app.db.oper.mediaserver",
"app.startup.initializers.modules -> app.db.oper.message",
"app.startup.initializers.modules -> app.db.oper.passkey",
@@ -8080,7 +8102,6 @@
"app.startup.initializers.modules -> app.db.oper.subscribehistory",
"app.startup.initializers.modules -> app.db.oper.systemconfig",
"app.startup.initializers.modules -> app.db.oper.transferhistory",
"app.startup.initializers.modules -> app.db.oper.userconfig",
"app.startup.initializers.modules -> app.db.oper.workflow",
"app.startup.initializers.modules -> app.db.session",
"app.startup.initializers.modules -> app.db.uow",
@@ -8239,10 +8260,10 @@
"app.testing.bootstrap -> app.application.service",
"app.testing.bootstrap -> app.db",
"app.testing.bootstrap -> app.db.adapters",
"app.testing.bootstrap -> app.db.adapters.configuration",
"app.testing.bootstrap -> app.db.adapters.transaction",
"app.testing.bootstrap -> app.db.oper",
"app.testing.bootstrap -> app.db.oper.systemconfig",
"app.testing.bootstrap -> app.db.oper.userconfig",
"app.testing.bootstrap -> app.db.session",
"app.testing.bootstrap -> app.db.uow",
"app.testing.bootstrap -> app.startup",
@@ -8424,7 +8445,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 853,
"module_count": 856,
"modules": [
"app",
"app.adapters",
@@ -8818,7 +8839,10 @@
"app.db",
"app.db.adapters",
"app.db.adapters.chain",
"app.db.adapters.configuration",
"app.db.adapters.download",
"app.db.adapters.history",
"app.db.adapters.history.download",
"app.db.adapters.mediaserver",
"app.db.adapters.outbox",
"app.db.adapters.pluginidentity",
+1 -6
View File
@@ -131,11 +131,6 @@
"target": "app.adapters.network.http",
"tracking": "S2-L6"
},
{
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis",
"tracking": "S2-L4"
},
{
"source": "app.application.torrent",
"target": "app.adapters.network.http",
@@ -291,7 +286,7 @@
"owner": "$source",
"reason": "精确 Adapter source 持有其 Redis、浏览器、HTTP 或 DNS transport;上层只能消费 Port 或统一 Facade。",
"facts": [
{"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7"},
{"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81"},
{"source": "app.adapters.network.browser", "target": "cloakbrowser", "kind": "network_sdk", "fingerprint": "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173"},
{"source": "app.adapters.network.doh", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71"},
{"source": "app.adapters.network.doh", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b"},
+7 -21
View File
@@ -495,9 +495,8 @@
},
"app/agent/tools/impl/query_download_tasks.py": {
"arg-type": 6,
"assignment": 1,
"misc": 1,
"no-any-return": 2,
"no-any-return": 1,
"no-untyped-def": 2
},
"app/agent/tools/impl/query_downloaders.py": {
@@ -1128,10 +1127,6 @@
"type-arg": 1,
"union-attr": 2
},
"app/application/download/tasks.py": {
"assignment": 1,
"type-arg": 1
},
"app/application/downloader.py": {
"arg-type": 1,
"no-untyped-def": 1
@@ -1271,9 +1266,6 @@
"app/application/security/otp.py": {
"no-any-return": 3
},
"app/application/security/passkey.py": {
"no-untyped-call": 1
},
"app/application/security/token.py": {
"no-any-return": 3,
"operator": 1
@@ -1319,7 +1311,7 @@
"type-arg": 3
},
"app/application/subscription/complete.py": {
"arg-type": 3
"arg-type": 2
},
"app/application/subscription/contract.py": {
"arg-type": 2,
@@ -1391,7 +1383,7 @@
"arg-type": 9,
"assignment": 4,
"attr-defined": 55,
"no-any-return": 4,
"no-any-return": 3,
"no-untyped-call": 1,
"no-untyped-def": 8,
"return-value": 2,
@@ -1748,9 +1740,6 @@
"no-untyped-def": 2,
"type-arg": 4
},
"app/db/oper/userconfig.py": {
"no-untyped-def": 4
},
"app/db/oper/workflow.py": {
"arg-type": 1,
"no-any-return": 6,
@@ -3364,9 +3353,6 @@
"app/schemas/workflow.py": {
"misc": 19
},
"app/sdk/_legacy/transfer.py": {
"no-untyped-def": 1
},
"app/sdk/string.py": {
"attr-defined": 1,
"no-untyped-def": 2,
@@ -3394,13 +3380,13 @@
"no-untyped-def": 2
},
"app/startup/initializers/modules.py": {
"arg-type": 16,
"arg-type": 13,
"assignment": 1,
"attr-defined": 2,
"misc": 1,
"no-any-return": 2,
"no-untyped-call": 33,
"no-untyped-def": 12,
"no-untyped-call": 32,
"no-untyped-def": 7,
"return-value": 5
},
"app/startup/initializers/plugins.py": {
@@ -3431,7 +3417,7 @@
"type-arg": 1
},
"app/testing/bootstrap.py": {
"no-untyped-call": 3,
"no-untyped-call": 2,
"no-untyped-def": 3,
"type-arg": 6
},
+1 -69
View File
@@ -164,9 +164,6 @@
"app/agent/tools/impl/query_custom_filter_rules.py": {
"I001": 1
},
"app/agent/tools/impl/query_download_tasks.py": {
"I001": 1
},
"app/agent/tools/impl/query_installed_plugins.py": {
"I001": 1
},
@@ -300,9 +297,6 @@
"app/application/formatting.py": {
"I001": 1
},
"app/application/history.py": {
"I001": 1
},
"app/application/image.py": {
"I001": 1
},
@@ -327,9 +321,6 @@
"app/application/notification.py": {
"I001": 1
},
"app/application/outbox.py": {
"I001": 1
},
"app/application/plugin/runtime.py": {
"I001": 1
},
@@ -345,22 +336,10 @@
"app/application/storage.py": {
"I001": 1
},
"app/application/subscription/delete.py": {
"I001": 1
},
"app/application/subscription/identity.py": {
"I001": 1
},
"app/application/subscription/mutation.py": {
"I001": 1
},
"app/application/subscription/priority.py": {
"F401": 1,
"I001": 1
},
"app/application/subscription/write.py": {
"I001": 1
},
"app/application/torrent.py": {
"F541": 3,
"I001": 1
@@ -380,9 +359,6 @@
"app/db/__init__.py": {
"I001": 1
},
"app/db/adapters/outbox.py": {
"I001": 1
},
"app/db/adapters/transaction.py": {
"I001": 1
},
@@ -392,9 +368,6 @@
"app/db/diagnostics.py": {
"I001": 1
},
"app/db/engine.py": {
"I001": 1
},
"app/db/models/__init__.py": {
"I001": 1
},
@@ -410,12 +383,6 @@
"app/db/models/message.py": {
"I001": 1
},
"app/db/models/outbox.py": {
"I001": 1
},
"app/db/models/passkey.py": {
"I001": 1
},
"app/db/models/plugindata.py": {
"I001": 1
},
@@ -440,12 +407,6 @@
"app/db/models/systemconfig.py": {
"I001": 1
},
"app/db/models/user.py": {
"I001": 1
},
"app/db/models/userconfig.py": {
"I001": 1
},
"app/db/models/workflow.py": {
"I001": 1
},
@@ -461,9 +422,6 @@
"app/db/oper/user.py": {
"I001": 1
},
"app/db/oper/userconfig.py": {
"I001": 1
},
"app/db/session.py": {
"I001": 1
},
@@ -889,8 +847,7 @@
"I001": 1
},
"app/runtime/cache.py": {
"E731": 2,
"I001": 1
"E731": 2
},
"app/runtime/capabilities/__init__.py": {
"I001": 1
@@ -910,9 +867,6 @@
"app/runtime/dependencies.py": {
"I001": 1
},
"app/runtime/event/dispatch.py": {
"I001": 1
},
"app/runtime/event/errors.py": {
"I001": 1
},
@@ -998,9 +952,6 @@
"app/sdk/_legacy/user.py": {
"I001": 1
},
"app/sdk/cache.py": {
"I001": 1
},
"app/sdk/config.py": {
"I001": 1
},
@@ -1176,9 +1127,6 @@
"tests/test_builtin_skill_boundaries.py": {
"I001": 1
},
"tests/test_cache_system.py": {
"I001": 1
},
"tests/test_capability_registry.py": {
"I001": 1
},
@@ -1198,9 +1146,6 @@
"tests/test_coalesce.py": {
"I001": 1
},
"tests/test_configuration_initializer.py": {
"I001": 1
},
"tests/test_dashboard_system_info.py": {
"I001": 1
},
@@ -1295,10 +1240,6 @@
"tests/test_feedback_issue_scripts.py": {
"I001": 1
},
"tests/test_feishu.py": {
"E402": 5,
"I001": 1
},
"tests/test_feishu_media_message.py": {
"E402": 3
},
@@ -1419,9 +1360,6 @@
"tests/test_metamusic.py": {
"I001": 1
},
"tests/test_mfa_passkey_registration_errors.py": {
"I001": 1
},
"tests/test_module_manager_capability_adapter.py": {
"I001": 1
},
@@ -1495,9 +1433,6 @@
"tests/test_observability.py": {
"I001": 1
},
"tests/test_passkey_challenge.py": {
"I001": 1
},
"tests/test_password_hashing.py": {
"I001": 1
},
@@ -1621,9 +1556,6 @@
"tests/test_string_compat.py": {
"I001": 1
},
"tests/test_subscribe_delete_command.py": {
"I001": 1
},
"tests/test_subscribe_oper.py": {
"F401": 1
},
+90 -85
View File
@@ -1621,14 +1621,14 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"29f02d6f5d4a82f6e2d304b899ee9d652dc5d3a1f375e45da2a7c7ba72fad828",
"fb5095b18017555a9da2ee5f5442814a0c783e61d99cec42c7f464c5a25a0968"
"e4ecf33c77ee608912dc20e6840caf13f01a6ad250867a7766e92fef4da17b4b"
]
},
"EventType.AudioTransferFailed": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"9e26ea89da42c77926126f7440c0365e24a63d4eb10280b270d29bf767bd47cf",
"c32e40027e195e6b664a7d5d287554804c65d04187dfbf5d922a7d832efd9ef0"
"c32e40027e195e6b664a7d5d287554804c65d04187dfbf5d922a7d832efd9ef0",
"e024ae64f7aa0245c005d1173d203a4aaf8aedaa18fb38a3fbe63776c96f160b"
]
},
"EventType.CommandExcute": {
@@ -1659,7 +1659,7 @@
"EventType.DownloadAdded": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"090d218ce64d85213d4d0c51eac059a36365f0edf6976acb9d4588e921d63587",
"912f9d1ffc673cab43f157ea3674122c84591b695761275cee39cc74ced7011d",
"f2e4ec70164d8ccb46a0f032c57c6c0452bee9ee55a2aa69e4f770e7d2c855b3",
"f2e4ec70164d8ccb46a0f032c57c6c0452bee9ee55a2aa69e4f770e7d2c855b3"
]
@@ -1764,16 +1764,16 @@
"EventType.SubscribeAdded": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"1398d8796771fb8848bf67228b6de8a9d7047f681bab989eefe845f58ad5184c",
"6dd6b59761660ab65659ea4e14c06108aa850c8d62fabf50391632ba2e74d8fe",
"79efb9919712de5265f3e9f8aafbdf53eede9a42c9c5d8bedb402ee6df9fab68",
"cdc72f0211e4bd640c76419d7e0266b95a542f84eeeabc45654e1a66c6718688"
]
},
"EventType.SubscribeComplete": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"8d8703c6323a227c4dfe4234e7d3056abb815e284464122faeccca87b050f3aa",
"a77dde8c737dd5636384a68b161220888639705a08089d62565452d4ca9b451c"
"a77dde8c737dd5636384a68b161220888639705a08089d62565452d4ca9b451c",
"ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b"
]
},
"EventType.SubscribeDeleted": {
@@ -1781,7 +1781,7 @@
"producer_fingerprints": [
"49cb2961dd38ea10d2a53c7208fb3ca66ecda4c7c2f8cb2c29ea3ac62629a512",
"7ee0ba0bfdba520a389d45c33d361120a0d710c3ea37b1e03532dd7364a52d47",
"b8610de798a3412aec42fde23dae3662f29464f3e1bea538f3e18156fcd65841",
"a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b",
"cbff1d6d00bcade626974178f292471c2bb7643ceb9360a0ad9fa23b87c3597d"
]
},
@@ -1789,17 +1789,17 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"330678cc36e39079b052e5a6f1493cd38cf40565ddf8b231dc20c94207cb1ba9",
"3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b",
"4204fef37b0e1b87ff665209b1085359b778096827bed514cf61c6488ede3ee3",
"4fc5c37849498747d5d61944b47381612c88d9b6320fda9ab48e3c1fa2444232",
"89165b03827d9801a260dd54dd03e77f903795b8872bd043a79e8283181c786f",
"8d7d49151bd35a4eb2fdc8d82f514484d7f04146718129d3f429ab847d38f6d8",
"ca81edd9f904843fdaaec15c0b774861c055742cdb0a27215ad43b0acfeaa73b"
]
},
"EventType.SubtitleTransferComplete": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"10a4bd31fc73023e9abf0fc63c64bb43a1dfec208c546b603f687462a2cda27a",
"814318ca189db145cccf38f4ae2fb972988af23e13c39d1c3b267bd9fa5bc976",
"9839276934bc6e557b3ec07a8a3f1d4b372f4dc792eb6198650e940140231083"
]
},
@@ -1807,7 +1807,7 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"1c2a6b622bf96b2c46e0831c124e24f2810bbbd8b8f0ffd259a7e6912ec207b6",
"70effe3d0dbcb65ef333864d678cd7cc01f89849cdb2cb3e358acbc751548451"
"f2fa83463c2e723b08aef8c1ca4c3286fa18a7e1caffa3a24a656d728d47db0d"
]
},
"EventType.SystemError": {
@@ -1823,13 +1823,13 @@
"consumer_fingerprints": [],
"producer_fingerprints": [
"063a823d8634ee118279717c8a9ed35eaae48a0533f05d472180572ce4f50096",
"e00dae2c489550d72bfd2962b0ab5bdfa3992cddf012fd6827bab9a77fa17f22"
"f9e1d110de9e4a9c8490f09e5f3b7312b293f51ad95180f8fe7af15607c12307"
]
},
"EventType.TransferFailed": {
"consumer_fingerprints": [],
"producer_fingerprints": [
"67272ed4bd81ea85c2e276e04b413f6d33665d260e4ff64aa900beb7fba8e071",
"be58b678d9a1f8c403e535c1836d2c509c7d34b664253cfe54da276e0d8c889c",
"ec3818c033105e65936b400e72d6832cef6d6a4bec84286ed9cab6596b09ac70"
]
},
@@ -2856,64 +2856,16 @@
"qualname": "_publish_modified",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.DownloadAdded"
],
"fingerprint": "090d218ce64d85213d4d0c51eac059a36365f0edf6976acb9d4588e921d63587",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubtitleTransferComplete"
],
"fingerprint": "10a4bd31fc73023e9abf0fc63c64bb43a1dfec208c546b603f687462a2cda27a",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.TransferFailed"
],
"fingerprint": "67272ed4bd81ea85c2e276e04b413f6d33665d260e4ff64aa900beb7fba8e071",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubtitleTransferFailed"
],
"fingerprint": "70effe3d0dbcb65ef333864d678cd7cc01f89849cdb2cb3e358acbc751548451",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubscribeAdded"
],
"fingerprint": "79efb9919712de5265f3e9f8aafbdf53eede9a42c9c5d8bedb402ee6df9fab68",
"fingerprint": "1398d8796771fb8848bf67228b6de8a9d7047f681bab989eefe845f58ad5184c",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
@@ -2922,34 +2874,34 @@
"events": [
"EventType.SubscribeModified"
],
"fingerprint": "8d7d49151bd35a4eb2fdc8d82f514484d7f04146718129d3f429ab847d38f6d8",
"fingerprint": "3561ca8eed8a851c88889ded56d3188b89ad8c7d6c833d3aca24c5c8e9bbc22b",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubscribeComplete"
"EventType.SubtitleTransferComplete"
],
"fingerprint": "8d8703c6323a227c4dfe4234e7d3056abb815e284464122faeccca87b050f3aa",
"fingerprint": "814318ca189db145cccf38f4ae2fb972988af23e13c39d1c3b267bd9fa5bc976",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.AudioTransferFailed"
"EventType.DownloadAdded"
],
"fingerprint": "9e26ea89da42c77926126f7440c0365e24a63d4eb10280b270d29bf767bd47cf",
"fingerprint": "912f9d1ffc673cab43f157ea3674122c84591b695761275cee39cc74ced7011d",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
@@ -2958,22 +2910,34 @@
"events": [
"EventType.SubscribeDeleted"
],
"fingerprint": "b8610de798a3412aec42fde23dae3662f29464f3e1bea538f3e18156fcd65841",
"fingerprint": "a2252a9f53ea9382e0a9a05564a04440cf1ba9fdf6a8e2fa6c426a20b473bf3b",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.TransferComplete"
"EventType.TransferFailed"
],
"fingerprint": "e00dae2c489550d72bfd2962b0ab5bdfa3992cddf012fd6827bab9a77fa17f22",
"fingerprint": "be58b678d9a1f8c403e535c1836d2c509c7d34b664253cfe54da276e0d8c889c",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.AudioTransferFailed"
],
"fingerprint": "e024ae64f7aa0245c005d1173d203a4aaf8aedaa18fb38a3fbe63776c96f160b",
"invalid": false,
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
@@ -2982,10 +2946,46 @@
"events": [
"EventType.AudioTransferComplete"
],
"fingerprint": "fb5095b18017555a9da2ee5f5442814a0c783e61d99cec42c7f464c5a25a0968",
"fingerprint": "e4ecf33c77ee608912dc20e6840caf13f01a6ad250867a7766e92fef4da17b4b",
"invalid": false,
"method": "send_event",
"qualname": "_build_outbox_dispatcher",
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubscribeComplete"
],
"fingerprint": "ee17c108da3b9e5515176692af946dbff2d54372dc487c1a1880bc8f495dad7b",
"invalid": false,
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.SubtitleTransferFailed"
],
"fingerprint": "f2fa83463c2e723b08aef8c1ca4c3286fa18a7e1caffa3a24a656d728d47db0d",
"invalid": false,
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
"caller": "app.startup.initializers.modules",
"dynamic": false,
"events": [
"EventType.TransferComplete"
],
"fingerprint": "f9e1d110de9e4a9c8490f09e5f3b7312b293f51ad95180f8fe7af15607c12307",
"invalid": false,
"method": "send_event_strict",
"qualname": "_build_outbox_handlers",
"receiver_kind": "constructed_manager"
},
{
@@ -9748,6 +9748,11 @@
"name": "AsyncRedisBackend",
"target": "app.adapters.cache.backends.AsyncRedisBackend"
},
{
"kind": "import",
"name": "AtomicCacheBackend",
"target": "app.runtime.cache.AtomicCacheBackend"
},
{
"kind": "import",
"name": "Cache",
+125 -125
View File
@@ -1,41 +1,41 @@
{
"schema_version": 2,
"generated_at": "2026-08-27T23:28:48.301843+00:00",
"generated_at": "2026-08-28T02:17:28.865452+00:00",
"platform": "macOS-26.5.2-arm64-arm-64bit-Mach-O",
"python": "3.14.3",
"repeat": 3,
"targets": {
"app.startup.lifecycle": {
"loaded_app_module_count": 394,
"max_ms": 1298.973,
"median_ms": 999.845,
"min_ms": 994.938,
"loaded_app_module_count": 398,
"max_ms": 1026.146,
"median_ms": 992.33,
"min_ms": 991.09,
"samples_ms": [
1298.973,
999.845,
994.938
1026.146,
992.33,
991.09
]
},
"app.factory": {
"loaded_app_module_count": 406,
"max_ms": 1079.626,
"median_ms": 1019.979,
"min_ms": 1014.804,
"loaded_app_module_count": 410,
"max_ms": 1065.169,
"median_ms": 1013.553,
"min_ms": 1013.18,
"samples_ms": [
1019.979,
1014.804,
1079.626
1065.169,
1013.553,
1013.18
]
},
"app.main": {
"loaded_app_module_count": 408,
"max_ms": 1075.788,
"median_ms": 1060.558,
"min_ms": 1057.105,
"loaded_app_module_count": 412,
"max_ms": 1069.112,
"median_ms": 1062.81,
"min_ms": 1062.635,
"samples_ms": [
1075.788,
1060.558,
1057.105
1069.112,
1062.81,
1062.635
]
}
},
@@ -48,85 +48,85 @@
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.674,
"full_lifespan_ms": 1.563,
"full_lifespan_ms": 1.508,
"stage_ms": {
"后台任务登记器": 0.095,
"数据库准备": 0.036,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.026,
"路由": 0.023,
"模块服务": 0.026,
"插件备份恢复": 0.022,
"插件": 0.021,
"定时器": 0.028,
"监控器": 0.02,
"待处理整理回放": 0.023,
"命令服务": 0.022,
"工作流": 0.025,
"插件同步与启动收尾": 0.02
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.654,
"full_lifespan_ms": 1.521,
"stage_ms": {
"后台任务登记器": 0.081,
"数据库准备": 0.039,
"HTTP 基础能力": 0.031,
"数据库准备": 0.037,
"HTTP 基础能力": 0.029,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.024,
"路由": 0.026,
"模块服务": 0.026,
"插件备份恢复": 0.024,
"插件": 0.025,
"定时器": 0.026,
"监控器": 0.023,
"待处理整理回放": 0.021,
"命令服务": 0.026,
"工作流": 0.023,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.662,
"full_lifespan_ms": 1.526,
"stage_ms": {
"后台任务登记器": 0.073,
"数据库准备": 0.036,
"HTTP 基础能力": 0.034,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.024,
"数据库连接预算": 0.025,
"路由": 0.025,
"模块服务": 0.024,
"插件备份恢复": 0.027,
"插件": 0.024,
"定时器": 0.025,
"监控器": 0.024,
"待处理整理回放": 0.023,
"命令服务": 0.023,
"工作流": 0.023,
"插件同步与启动收尾": 0.023
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.651,
"full_lifespan_ms": 1.495,
"stage_ms": {
"后台任务登记器": 0.077,
"数据库准备": 0.038,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.024,
"路由": 0.026,
"模块服务": 0.025,
"插件备份恢复": 0.023,
"插件": 0.024,
"定时器": 0.022,
"监控器": 0.023,
"待处理整理回放": 0.021,
"命令服务": 0.024,
"工作流": 0.021,
"插件同步与启动收尾": 0.024
},
"threads_before": 2,
"threads_started": 2,
"threads_after": 2,
"tasks_before": 1,
"tasks_started": 1,
"tasks_after": 1,
"database_connections_started": 0
},
{
"mode": "normal",
"enabled_component_count": 25,
"startup_ms": 0.696,
"full_lifespan_ms": 1.687,
"stage_ms": {
"后台任务登记器": 0.086,
"数据库准备": 0.039,
"HTTP 基础能力": 0.032,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.026,
"路由": 0.026,
"模块服务": 0.026,
"插件备份恢复": 0.022,
"插件": 0.025,
"定时器": 0.026,
"监控器": 0.024,
"定时器": 0.023,
"监控器": 0.025,
"待处理整理回放": 0.024,
"命令服务": 0.021,
"工作流": 0.022,
"命令服务": 0.024,
"工作流": 0.024,
"插件同步与启动收尾": 0.024
},
"threads_before": 2,
@@ -138,8 +138,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.662,
"median_full_lifespan_ms": 1.526,
"median_startup_ms": 0.674,
"median_full_lifespan_ms": 1.521,
"enabled_component_count": 25,
"enabled_components": [
"后台任务登记器",
@@ -174,18 +174,18 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.485,
"full_lifespan_ms": 0.915,
"startup_ms": 0.502,
"full_lifespan_ms": 0.905,
"stage_ms": {
"后台任务登记器": 0.08,
"数据库准备": 0.038,
"后台任务登记器": 0.081,
"数据库准备": 0.044,
"HTTP 基础能力": 0.03,
"领域依赖装配": 0.031,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.023,
"路由": 0.023,
"领域依赖装配": 0.03,
"数据库引擎预热": 0.027,
"数据库连接预算": 0.027,
"路由": 0.026,
"模块服务": 0.025,
"插件同步与启动收尾": 0.022
"插件同步与启动收尾": 0.023
},
"threads_before": 2,
"threads_started": 2,
@@ -198,18 +198,18 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.548,
"full_lifespan_ms": 0.942,
"startup_ms": 0.499,
"full_lifespan_ms": 0.921,
"stage_ms": {
"后台任务登记器": 0.091,
"数据库准备": 0.04,
"HTTP 基础能力": 0.042,
"领域依赖装配": 0.029,
"数据库引擎预热": 0.023,
"数据库连接预算": 0.029,
"路由": 0.024,
"模块服务": 0.022,
"插件同步与启动收尾": 0.021
"后台任务登记器": 0.081,
"数据库准备": 0.039,
"HTTP 基础能力": 0.031,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.026,
"路由": 0.026,
"模块服务": 0.025,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
@@ -222,18 +222,18 @@
{
"mode": "safe",
"enabled_component_count": 13,
"startup_ms": 0.496,
"full_lifespan_ms": 0.923,
"startup_ms": 0.507,
"full_lifespan_ms": 0.924,
"stage_ms": {
"后台任务登记器": 0.077,
"数据库准备": 0.035,
"HTTP 基础能力": 0.035,
"领域依赖装配": 0.026,
"数据库引擎预热": 0.025,
"数据库连接预算": 0.024,
"数据库准备": 0.037,
"HTTP 基础能力": 0.032,
"领域依赖装配": 0.028,
"数据库引擎预热": 0.026,
"数据库连接预算": 0.025,
"路由": 0.024,
"模块服务": 0.024,
"插件同步与启动收尾": 0.026
"模块服务": 0.025,
"插件同步与启动收尾": 0.025
},
"threads_before": 2,
"threads_started": 2,
@@ -244,8 +244,8 @@
"database_connections_started": 0
}
],
"median_startup_ms": 0.496,
"median_full_lifespan_ms": 0.923,
"median_startup_ms": 0.502,
"median_full_lifespan_ms": 0.921,
"enabled_component_count": 13,
"enabled_components": [
"后台任务登记器",
+2 -2
View File
@@ -7,10 +7,10 @@ import pytest
from app.application.messaging.agent import matches_channel_admin, resolve_config_principal_ids
from app.modules.discord import DiscordModule
from app.modules.feishu.feishu import Feishu
from app.modules.qqbot import QQBotModule
from app.modules.qqbot.module import QQBotModule
from app.modules.slack import SlackModule
from app.modules.synologychat import SynologyChatModule
from app.modules.telegram import TelegramModule
from app.modules.telegram.module import TelegramModule
from app.modules.vocechat import VoceChatModule
from app.modules.wechat import WechatModule
from app.modules.wechat.wechatbot import WeChatBot
+7 -9
View File
@@ -35,7 +35,6 @@ FROZEN_DIRECT_ADAPTER_IMPORTS = {
("app.application.security.cookie", "app.adapters.external.ocr"): "S2-L6",
("app.application.security.cookie", "app.adapters.network.browser"): "S2-L6",
("app.application.security.cookie", "app.adapters.network.http"): "S2-L6",
("app.application.security.passkey", "app.adapters.cache.redis"): "S2-L4",
("app.application.torrent", "app.adapters.network.http"): "S2-L6",
("app.application.transfer.workflow", "app.adapters.system.host"): "S2-L6",
("app.chain._recognition", "app.adapters.external.server"): "S2-L7",
@@ -261,7 +260,6 @@ def test_current_direct_adapter_imports_match_temporary_debt_policy() -> None:
assert _adapter_policy_scope_errors(adapter_policy["scope"], contract["scope"]) == []
assert entries == sorted(entries, key=lambda item: (item["source"], item["target"]))
assert Counter(FROZEN_DIRECT_ADAPTER_IMPORTS.values()) == {
"S2-L4": 1,
"S2-L5": 1,
"S2-L6": 13,
"S2-L7": 13,
@@ -323,9 +321,9 @@ def test_adapter_policy_rejects_add_remove_and_replacement() -> None:
def test_adapter_policy_rejects_manual_policy_bypasses() -> None:
"""手工 policy 也不能接纳新边、错 owner、重复项或越界范围。"""
valid = {
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis",
"tracking": "S2-L4",
"source": "app.application.backup",
"target": "app.adapters.system.backup.files",
"tracking": "S2-L5",
}
invalid_entries = [
{
@@ -336,18 +334,18 @@ def test_adapter_policy_rejects_manual_policy_bypasses() -> None:
{
"source": valid["source"],
"target": "app.adapters.network.browser",
"tracking": "S2-L4",
"tracking": "S2-L5",
},
{**valid, "tracking": "S2-L5"},
{**valid, "tracking": "S2-L6"},
{
"source": "app.api.sample",
"target": valid["target"],
"tracking": "S2-L4",
"tracking": "S2-L5",
},
{
"source": valid["source"],
"target": "app.db.adapters.subscription",
"tracking": "S2-L4",
"tracking": "S2-L5",
},
{
"source": "app.application.*",
+188
View File
@@ -491,6 +491,160 @@ def test_download_failure_and_mediaserver_ports_are_typed_and_detached():
assert "TransactionalMediaServerRepository(SessionFactory)" in startup_source
def test_download_history_ports_are_typed_detached_and_canonically_injected():
"""下载历史宿主调用面只能消费冻结快照和显式事务 adapter。"""
history_path = APP_ROOT / "application" / "history.py"
history_tree = ast.parse(
history_path.read_text(encoding="utf-8"),
filename=str(history_path),
)
classes = {
node.name: node
for node in history_tree.body
if isinstance(node, ast.ClassDef)
}
for class_name in (
"DownloadHistorySnapshot",
"DownloadFileSnapshot",
"DownloadHistoryWrite",
"DownloadFileWrite",
):
decorator = next(
item
for item in classes[class_name].decorator_list
if isinstance(item, ast.Call)
and isinstance(item.func, ast.Name)
and item.func.id == "dataclass"
)
keywords = {
item.arg: ast.literal_eval(item.value)
for item in decorator.keywords
}
assert keywords == {"frozen": True, "slots": True}
for class_name in ("DownloadHistoryQueryPort", "DownloadHistoryWritePort"):
annotations = [
ast.unparse(node.returns)
for node in classes[class_name].body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.returns is not None
]
assert annotations
assert all("Any" not in annotation for annotation in annotations)
data_path = APP_ROOT / "application" / "chain" / "data.py"
data_tree = ast.parse(data_path.read_text(encoding="utf-8"), filename=str(data_path))
data_class = next(
node
for node in data_tree.body
if isinstance(node, ast.ClassDef) and node.name == "ChainDataPorts"
)
annotations = {
node.target.id: ast.unparse(node.annotation)
for node in data_class.body
if isinstance(node, ast.AnnAssign)
and isinstance(node.target, ast.Name)
}
returns = {
node.name: ast.unparse(node.returns)
for node in ast.walk(data_tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.returns is not None
}
agent_tree = ast.parse(
(APP_ROOT / "application" / "agentdata.py").read_text(encoding="utf-8")
)
agent_return = next(
ast.unparse(node.returns)
for node in ast.walk(agent_tree)
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.name == "get_agent_download_history_port"
and node.returns is not None
)
assert annotations["download_history"] == "DownloadHistoryRepositoryFactory"
assert returns["get_chain_download_history_port"] == "DownloadHistoryRepository"
assert agent_return == "DownloadHistoryRepository"
consumer_paths = (
APP_ROOT / "chain" / "_transfer.py",
APP_ROOT / "chain" / "download.py",
APP_ROOT / "chain" / "transfer.py",
APP_ROOT / "agent" / "tools" / "impl" / "query_download_tasks.py",
APP_ROOT / "agent" / "tools" / "impl" / "delete_download_history.py",
APP_ROOT / "application" / "transfer" / "workflow.py",
)
for path in consumer_paths:
source = path.read_text(encoding="utf-8")
assert "app.db.oper.downloadhistory" not in source
assert "DownloadHistory = Any" not in source
assert "DownloadFiles = Any" not in source
startup_source = (
APP_ROOT / "startup" / "initializers" / "modules.py"
).read_text(encoding="utf-8")
assert "TransactionalDownloadHistoryRepository(" in startup_source
assert "SessionDownloadHistoryRepository" in startup_source
assert "DownloadHistoryOper" not in startup_source
adapter_source = (
APP_ROOT / "db" / "adapters" / "history" / "download.py"
).read_text(encoding="utf-8")
assert "class TransactionalDownloadHistoryRepository" in adapter_source
assert "class SessionDownloadHistoryRepository" in adapter_source
assert "_project_history" in adapter_source
assert "SqlAlchemyUnitOfWork" in adapter_source
legacy_source = (
APP_ROOT / "sdk" / "_legacy" / "transfer.py"
).read_text(encoding="utf-8")
assert "download_history: Optional[Any]" in legacy_source
def test_user_configuration_uses_typed_transactional_adapter():
"""用户配置宿主入口只消费类型化端口,旧 Oper 写入口仅承担兼容 ABI。"""
application_path = APP_ROOT / "application" / "security" / "userconfig.py"
application_source = application_path.read_text(encoding="utf-8")
application_tree = ast.parse(application_source, filename=str(application_path))
repository = next(
node
for node in application_tree.body
if isinstance(node, ast.ClassDef)
and node.name == "UserConfigurationRepository"
)
returns = {
node.name: ast.unparse(node.returns)
for node in repository.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
and node.returns is not None
}
assert returns == {
"get": "JsonData",
"set": "None",
"publish_rename": "None",
"publish_delete": "None",
}
assert "Any" not in application_source
startup_source = (
APP_ROOT / "startup" / "initializers" / "modules.py"
).read_text(encoding="utf-8")
assert "TransactionalUserConfigurationRepository(SessionFactory)" in startup_source
assert "UserConfigOper" not in startup_source
adapter_path = APP_ROOT / "db" / "adapters" / "configuration.py"
adapter_source = adapter_path.read_text(encoding="utf-8")
assert "class TransactionalUserConfigurationRepository" in adapter_source
assert "SqlAlchemyUnitOfWork" in adapter_source
assert "Any" not in adapter_source
oper_source = (
APP_ROOT / "db" / "oper" / "userconfig.py"
).read_text(encoding="utf-8")
assert "def stage_set(" in oper_source
assert "def set(" in oper_source
assert "Any" not in oper_source
def test_canonical_workflow_oper_has_no_legacy_writer_or_duplicate_exports():
"""工作流旧写入口只能存在于 SDK Legacy facade。"""
oper_path = APP_ROOT / "db" / "oper" / "workflow.py"
@@ -1467,6 +1621,40 @@ def test_cache_contract_does_not_import_concrete_adapters():
} == set()
def test_passkey_application_does_not_select_cache_backend():
"""PassKey 用例只消费原子缓存端口,不得识别 Redis 或后端类型。"""
modules = _discover_modules()
path = modules["app.application.security.passkey"]
dependencies = _resolve_imports(
"app.application.security.passkey",
path,
set(modules),
)
source = path.read_text(encoding="utf-8-sig")
assert {
dependency
for dependency in dependencies
if dependency.startswith("app.adapters.cache")
} == set()
assert "RedisHelper" not in source
assert ".is_redis(" not in source
def test_startup_explicitly_configures_passkey_challenge_cache():
"""PassKey challenge 缓存必须由启动组合根显式装配。"""
path = APP_ROOT / "startup" / "initializers" / "modules.py"
tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path))
configured = any(
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "configure_passkey_challenge_cache"
for node in ast.walk(tree)
)
assert configured is True
def test_resource_adapter_does_not_restart_process():
"""资源下载安装适配器不得反向调用进程重启能力。"""
modules = _discover_modules()
+1 -1
View File
@@ -110,7 +110,7 @@ FROZEN_EGRESS_REASON_BY_EDGE = {
for edge in edges
}
FROZEN_EGRESS_FINGERPRINT_BY_EDGE = {
("app.adapters.cache.redis", "redis"): "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7",
("app.adapters.cache.redis", "redis"): "49f0b28ef731b25aa772d6887febd762d03b981a1f52d6dfc8dff82f2f489f81",
("app.adapters.external.market", "httpx2"): "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f",
("app.adapters.external.market", "requests"): "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3",
("app.adapters.network.browser", "cloakbrowser"): "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173",
+9 -1
View File
@@ -699,6 +699,8 @@ import app.schemas.types as schema_types
receiver = eventmanager
emit = receiver.send_event
emit(EventType.Alpha)
strict_emit = receiver.send_event_strict
strict_emit(EventType.Beta)
EventManager().send_event(EventType.Gamma)
EventManager.get_existing_instance().send_event(ChainEventType.Delta)
@@ -710,7 +712,7 @@ async def publish():
)
assert facts["consumers"] == []
assert len(facts["producers"]) == 4
assert len(facts["producers"]) == 5
assert {
(
fact["qualname"],
@@ -721,6 +723,12 @@ async def publish():
for fact in facts["producers"]
} == {
("<module>", "send_event", "canonical_singleton", ("EventType.Alpha",)),
(
"<module>",
"send_event_strict",
"canonical_singleton",
("EventType.Beta",),
),
("<module>", "send_event", "constructed_manager", ("EventType.Gamma",)),
("<module>", "send_event", "existing_manager", ("ChainEventType.Delta",)),
("publish", "async_send_event", "canonical_singleton", ("EventType.Beta",)),
+86
View File
@@ -0,0 +1,86 @@
"""插件认证一次性票据的生命周期测试。"""
from concurrent.futures import ThreadPoolExecutor
import pytest
from app.application.security import auth
from app.application.security.auth import AuthTicketStore
@pytest.fixture(autouse=True)
def clear_auth_tickets():
"""隔离单例票据缓存,避免测试间共享认证事实。"""
store = AuthTicketStore()
with store._lock:
store._tickets.clear()
yield
with store._lock:
store._tickets.clear()
def test_auth_ticket_can_only_be_consumed_once():
"""成功领取后立即删除票据,后续兑换不得重复获得认证事实。"""
store = AuthTicketStore()
ticket = store.create(user_id=1, provider_id="plugin:test")
assert store.consume(ticket)["user_id"] == 1
assert store.consume(ticket) is None
def test_auth_ticket_concurrent_consumers_have_single_winner():
"""多个并发兑换请求只能有一个成功领取同一票据。"""
store = AuthTicketStore()
ticket = store.create(user_id=1, provider_id="plugin:test")
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(lambda _: store.consume(ticket), range(8)))
assert sum(result is not None for result in results) == 1
def test_auth_ticket_ttl_boundary_and_expiration(monkeypatch):
"""票据在 TTL 边界内有效,超过边界后即使首次领取也必须失败。"""
now = [1_000.0]
monkeypatch.setattr(auth.time, "time", lambda: now[0])
store = AuthTicketStore()
boundary_ticket = store.create(user_id=1, provider_id="plugin:test")
now[0] += store._ttl_seconds
assert store.consume(boundary_ticket) is not None
expired_ticket = store.create(user_id=1, provider_id="plugin:test")
now[0] += store._ttl_seconds + 0.001
assert store.consume(expired_ticket) is None
assert store.consume(expired_ticket) is None
def test_auth_ticket_metadata_is_detached_from_callers():
"""签发和领取两侧都不能通过可变对象改写缓存中的认证元数据。"""
store = AuthTicketStore()
metadata = {"groups": ["users"]}
ticket = store.create(
user_id=1,
provider_id="plugin:test",
metadata=metadata,
)
metadata["groups"].append("admins")
consumed = store.consume(ticket)
assert consumed["metadata"] == {"groups": ["users"]}
def test_auth_ticket_capacity_is_a_hard_limit(monkeypatch):
"""连续签发也不得让票据表永久超过容量上限。"""
monkeypatch.setattr(AuthTicketStore, "_max_items", 4)
store = AuthTicketStore()
tickets = [
store.create(user_id=index, provider_id="plugin:test")
for index in range(6)
]
assert len(store._tickets) == 4
assert store.consume(tickets[0]) is None
assert store.consume(tickets[-1]) is not None
+110 -1
View File
@@ -2,6 +2,7 @@ import asyncio
import os
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
from unittest.mock import AsyncMock
@@ -13,15 +14,17 @@ from app.adapters.cache.backends import (
FileBackend,
RedisBackend,
)
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper, serialize
from app.runtime.cache import (
AsyncFileCache,
AsyncMemoryBackend,
FileCache,
MemoryBackend,
TTLCache,
cached,
)
from app.runtime.config import settings
from app.adapters.cache.redis import AsyncRedisHelper, RedisHelper, serialize
def test_file_backend_items_keep_relative_keys_and_bytes(tmp_path):
"""
@@ -720,6 +723,112 @@ def test_redis_helper_pop_uses_atomic_getdel():
assert calls == ["region:passkey_challenge:key:token"]
def test_redis_helper_strict_consume_propagates_backend_failure():
"""严格领取必须区分 Redis 故障与键不存在,旧 pop 仍保持兼容返回值。"""
class FailingClient:
"""模拟 GETDEL 连接故障。"""
def getdel(self, _key):
"""报告 Redis 命令失败。"""
raise ConnectionError("redis unavailable")
helper = RedisHelper()
helper.client = FailingClient()
try:
with pytest.raises(ConnectionError, match="redis unavailable"):
helper.consume("token", region="passkey_challenge")
assert helper.pop("token", region="passkey_challenge") is None
finally:
helper.client = None
def test_redis_helper_strict_store_requires_backend_acknowledgement():
"""严格写入不得把 Redis 未确认写入当成成功签发。"""
class RejectingClient:
"""模拟 Redis 拒绝确认 SET。"""
def set(self, *_args, **_kwargs):
"""返回未写入状态。"""
return False
helper = RedisHelper()
helper.client = RejectingClient()
try:
with pytest.raises(RuntimeError, match="not acknowledged"):
helper.store("token", "challenge", region="passkey_challenge")
helper.set("token", "challenge", region="passkey_challenge")
finally:
helper.client = None
def test_memory_atomic_cache_consume_has_single_winner_and_honors_ttl():
"""内存原子缓存与 Redis 一样只允许一次领取并服从 TTL。"""
cache = MemoryBackend(ttl=60)
region = "atomic_memory_contract"
cache.clear(region=region)
cache.store("token", "challenge", region=region)
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(
lambda _: cache.consume("token", region=region),
range(8),
))
assert results.count("challenge") == 1
assert results.count(None) == 7
cache.store("expired", "challenge", ttl=0, region=region)
assert cache.consume("expired", region=region) is None
def test_ttl_cache_legacy_pop_uses_atomic_consume_contract():
"""插件既有 TTLCache.pop 入口保持可用并获得原子领取语义。"""
cache = TTLCache(region="legacy_atomic_pop", maxsize=8, ttl=60)
cache.clear()
cache.set("token", "challenge")
def pop_token(_index):
"""兼容入口不存在时返回空值,便于汇总并发结果。"""
try:
return cache.pop("token")
except KeyError:
return None
with ThreadPoolExecutor(max_workers=8) as executor:
results = list(executor.map(pop_token, range(8)))
assert results.count("challenge") == 1
assert results.count(None) == 7
def test_redis_backend_strict_store_and_consume_use_atomic_helper():
"""Redis 适配器将严格缓存契约完整委托给底层原子实现。"""
calls = []
class FakeHelper:
"""记录严格 Redis 缓存调用。"""
def store(self, key, value, ttl=None, region=None, **kwargs):
"""记录严格写入。"""
calls.append(("store", key, value, ttl, region, kwargs))
def consume(self, key, region=None):
"""记录原子领取。"""
calls.append(("consume", key, region))
return "challenge"
backend = RedisBackend(ttl=60)
backend.redis_helper = FakeHelper()
backend.store("token", "challenge", region="passkey_challenge")
assert backend.consume("token", region="passkey_challenge") == "challenge"
assert calls == [
("store", "token", "challenge", 60, "passkey_challenge", {}),
("consume", "token", "passkey_challenge"),
]
def test_async_redis_helper_uses_blocking_pool_settings(monkeypatch):
"""
Redis 异步客户端应使用阻塞连接池,避免高并发缓存读取立刻抛出连接耗尽错误。
+28 -24
View File
@@ -20,7 +20,11 @@ from app.application.chain.events import (
snapshot_transfer_result,
transfer_result_event_key,
)
from app.application.history import TransferHistoryMutationCommand
from app.application.history import (
DownloadFileWrite,
DownloadHistoryWrite,
TransferHistoryMutationCommand,
)
from app.application.transfer.execution import (
TransferExecutionCheckpoint,
TransferExecutionConflictError,
@@ -250,22 +254,22 @@ def test_download_history_and_event_intent_share_one_transaction():
calls = []
writer.download_added(
history_payload={
"path": "/downloads/Demo.mkv",
"type": MediaType.MOVIE.value,
"title": "Demo",
"download_hash": "hash-2",
},
file_payloads=[
{
"download_hash": "hash-2",
"downloader": "qb",
"fullpath": "/downloads/Demo.mkv",
"savepath": "/downloads",
"filepath": "Demo.mkv",
"torrentname": "Demo torrent",
}
],
history=DownloadHistoryWrite(
path="/downloads/Demo.mkv",
type=MediaType.MOVIE.value,
title="Demo",
download_hash="hash-2",
),
files=(
DownloadFileWrite(
download_hash="hash-2",
downloader="qb",
fullpath="/downloads/Demo.mkv",
savepath="/downloads",
filepath="Demo.mkv",
torrentname="Demo torrent",
),
),
event_payload={
"hash": "hash-2",
"context": context,
@@ -292,13 +296,13 @@ def test_download_history_and_event_intent_share_one_transaction():
]
writer.download_added(
history_payload={
"path": "/downloads/duplicate.mkv",
"type": MediaType.MOVIE.value,
"title": "Duplicate",
"download_hash": "hash-2",
},
file_payloads=[],
history=DownloadHistoryWrite(
path="/downloads/duplicate.mkv",
type=MediaType.MOVIE.value,
title="Duplicate",
download_hash="hash-2",
),
files=(),
event_payload={
"hash": "hash-2",
"context": context,
+11 -3
View File
@@ -4,9 +4,9 @@ from unittest.mock import AsyncMock
import pytest
from app.application.configuration import configure_runtime_settings
from app.startup.initializers import modules as modules_initializer
from app.startup.lifecycle import initialize_modules_component
from app.application.configuration import configure_runtime_settings
class _InlineWorker:
@@ -74,7 +74,11 @@ async def test_configuration_services_publish_after_both_snapshots_load(
events.append("load-user")
monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig)
monkeypatch.setattr(modules_initializer, "UserConfigOper", _UserConfig)
monkeypatch.setattr(
modules_initializer,
"TransactionalUserConfigurationRepository",
lambda _session_factory: _UserConfig(),
)
monkeypatch.setattr(
modules_initializer,
"configure_system_config",
@@ -112,7 +116,11 @@ async def test_configuration_load_failure_does_not_publish_partial_service(
raise RuntimeError("load failed")
monkeypatch.setattr(modules_initializer, "SystemConfigOper", _SystemConfig)
monkeypatch.setattr(modules_initializer, "UserConfigOper", _UserConfig)
monkeypatch.setattr(
modules_initializer,
"TransactionalUserConfigurationRepository",
lambda _session_factory: _UserConfig(),
)
monkeypatch.setattr(
modules_initializer,
"configure_system_config",
+2 -2
View File
@@ -117,8 +117,8 @@ def test_user_configuration_service_supports_sync_and_async_writes() -> None:
async_executor=_InlineDatabaseExecutor(),
)
assert service.set("alice", "theme", "dark") is True
assert asyncio.run(service.async_set("alice", "theme", "light")) is True
assert service.set("alice", "theme", "dark") is None
assert asyncio.run(service.async_set("alice", "theme", "light")) is None
assert repository.set.call_args_list == [
((), {"username": "alice", "key": "theme", "value": "dark"}),
((), {"username": "alice", "key": "theme", "value": "light"}),
+17 -17
View File
@@ -5,11 +5,13 @@ ORM 基类通用增删改查的行为。
任何一个出偏差都会同时影响所有表同步方法与其异步孪生方法必须给出相同结果
否则同一张表经 API异步与经调度任务同步会看到不同的数据
"""
import asyncio
import pytest
from app.db.models.systemconfig import SystemConfig
from app.db.models.user import User
from app.db.models.userconfig import UserConfig
from app.db.uow import run_async_transaction
@@ -30,9 +32,7 @@ def test_create_persists_and_get_reads_back(db):
assert row.id is not None
assert SystemConfig.get(db.session, row.id).key == "base-create"
assert db.run_async_session(
lambda session: SystemConfig.async_get(session, rid=row.id)
).key == "base-create"
assert db.run_async_session(lambda session: SystemConfig.async_get(session, rid=row.id)).key == "base-create"
def test_get_returns_none_for_missing_id(db):
@@ -40,9 +40,7 @@ def test_get_returns_none_for_missing_id(db):
主键不存在时返回 None而不是抛异常或返回任意一行
"""
assert SystemConfig.get(db.session, -1) is None
assert db.run_async_session(
lambda session: SystemConfig.async_get(session, rid=-1)
) is None
assert db.run_async_session(lambda session: SystemConfig.async_get(session, rid=-1)) is None
def test_async_create_flushes_and_assigns_primary_key(db):
@@ -51,11 +49,11 @@ def test_async_create_flushes_and_assigns_primary_key(db):
异步路径的调用方常常紧接着用 id 建立关联拿到 None 会让关联静默丢失
"""
created = asyncio.run(run_async_transaction(
lambda session: SystemConfig(
key="base-async-create", value={"n": 2}
).async_create(session)
))
created = asyncio.run(
run_async_transaction(
lambda session: SystemConfig(key="base-async-create", value={"n": 2}).async_create(session)
)
)
assert created.id is not None
assert SystemConfig.get(db.session, created.id).value == {"n": 2}
@@ -104,9 +102,7 @@ def test_async_delete_removes_only_the_given_row(db):
dropped = db.add(SystemConfig(key="base-async-del", value={"n": 1}))
kept = db.add(SystemConfig(key="base-async-keep", value={"n": 2}))
asyncio.run(run_async_transaction(
lambda session: SystemConfig.async_delete(session, rid=dropped.id)
))
asyncio.run(run_async_transaction(lambda session: SystemConfig.async_delete(session, rid=dropped.id)))
assert SystemConfig.get(db.session, dropped.id) is None
assert SystemConfig.get(db.session, kept.id) is not None
@@ -116,15 +112,14 @@ def test_async_delete_tolerates_missing_row(db):
"""
删除不存在的行不抛异常保持调用方的幂等语义
"""
asyncio.run(run_async_transaction(
lambda session: SystemConfig.async_delete(session, rid=-1)
))
asyncio.run(run_async_transaction(lambda session: SystemConfig.async_delete(session, rid=-1)))
def test_list_returns_every_row_of_that_model_only(db):
"""
列举必须限定在本模型对应的表不能跨表
"""
db.add(User(name="base-user"))
db.add(UserConfig(username="base-user", key="k", value="v"))
listed = UserConfig.list(db.session)
@@ -137,6 +132,7 @@ def test_async_list_matches_sync_list(db):
"""
同步与异步列举必须返回同一批主键
"""
db.add(User(name="base-list"))
db.add(UserConfig(username="base-list", key="k", value="v"))
sync_ids = sorted(item.id for item in UserConfig.list(db.session))
@@ -149,6 +145,10 @@ def test_truncate_empties_the_table(db):
"""
清表后该模型不再有任何行同步与异步实现须一致
"""
db.add(
User(name="base-truncate"),
User(name="base-truncate-async"),
)
db.add(UserConfig(username="base-truncate", key="k", value="v"))
UserConfig.truncate(db.session)
+171 -63
View File
@@ -5,6 +5,7 @@
一个能被日志发现的异常同步方法都有一个已是 2.0 写法的异步孪生方法这里对同一
批数据同时跑两条路径并要求结果一致同步侧改写后若有偏差这个断言会直接暴露
"""
import asyncio
import pytest
@@ -29,19 +30,17 @@ def _track(db):
# SystemConfig
# --------------------------------------------------------------------------- #
def test_systemconfig_get_by_key_matches_async_twin(db):
"""
按键取配置的同步与异步结果必须一致且只命中同名键
"""
db.add(SystemConfig(key="mp-test-a", value={"n": 1}),
SystemConfig(key="mp-test-b", value={"n": 2}))
db.add(SystemConfig(key="mp-test-a", value={"n": 1}), SystemConfig(key="mp-test-b", value={"n": 2}))
found = SystemConfig.get_by_key(db.session, "mp-test-a")
assert found.value == {"n": 1}
async_found = db.run_async_session(
lambda session: SystemConfig.async_get_by_key(session, "mp-test-a")
)
async_found = db.run_async_session(lambda session: SystemConfig.async_get_by_key(session, "mp-test-a"))
assert async_found.value == found.value
@@ -58,9 +57,7 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
monkeypatch.setattr(
db_base,
"run_sync_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外同步事务")),
)
assert SystemConfig.get_by_key(db.session, "mp-explicit-config") is not None
@@ -70,14 +67,15 @@ def test_systemconfig_queries_reuse_explicit_sessions(db, monkeypatch):
monkeypatch.setattr(
db_base,
"run_async_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外异步事务")),
)
assert (
await SystemConfig.async_get_by_key(
session,
"mp-explicit-config",
)
is not None
)
assert await SystemConfig.async_get_by_key(
session,
"mp-explicit-config",
) is not None
asyncio.run(check())
@@ -86,8 +84,7 @@ def test_systemconfig_delete_by_key_removes_only_that_key(db):
"""
按键删除只能删掉那一个键误删会静默丢失其他配置
"""
db.add(SystemConfig(key="mp-test-del", value={"n": 1}),
SystemConfig(key="mp-test-keep", value={"n": 2}))
db.add(SystemConfig(key="mp-test-del", value={"n": 1}), SystemConfig(key="mp-test-keep", value={"n": 2}))
assert SystemConfig().delete_by_key(db.session, "mp-test-del") is True
@@ -106,12 +103,15 @@ def test_systemconfig_delete_by_key_tolerates_missing_key(db):
# UserConfig
# --------------------------------------------------------------------------- #
def test_userconfig_get_by_key_scopes_by_username(db):
"""
用户配置必须同时按用户名和键命中只按键会把别人的配置读给当前用户
"""
db.add(UserConfig(username="alice", key="theme", value="dark"),
UserConfig(username="bob", key="theme", value="light"))
db.add(User(name="alice"), User(name="bob"))
db.add(
UserConfig(username="alice", key="theme", value="dark"), UserConfig(username="bob", key="theme", value="light")
)
assert UserConfig.get_by_key(db.session, username="alice", key="theme").value == "dark"
assert UserConfig.get_by_key(db.session, username="bob", key="theme").value == "light"
@@ -122,8 +122,10 @@ def test_userconfig_delete_by_key_removes_only_that_user(db):
"""
删除某用户的配置不能波及同名键的其他用户
"""
db.add(UserConfig(username="alice", key="theme", value="dark"),
UserConfig(username="bob", key="theme", value="light"))
db.add(User(name="alice"), User(name="bob"))
db.add(
UserConfig(username="alice", key="theme", value="dark"), UserConfig(username="bob", key="theme", value="light")
)
assert UserConfig().delete_by_key(db.session, username="alice", key="theme") is True
@@ -142,25 +144,21 @@ def test_userconfig_delete_by_key_tolerates_missing_row(db):
# User
# --------------------------------------------------------------------------- #
def test_user_lookup_by_name_and_id_matches_async_twin(db):
"""
按名与按 ID 取用户的同步异步结果必须指向同一行
登录链路走同步API 依赖注入走异步两者不一致会表现为能登录但查不到自己
"""
created = db.add(User(name="mp-test-user", email="u@example.com",
hashed_password="x", is_active=True))
created = db.add(User(name="mp-test-user", email="u@example.com", hashed_password="x", is_active=True))
by_name = User.get_by_name(db.session, "mp-test-user")
by_id = User.get_by_id(db.session, created.id)
assert by_name.id == by_id.id == created.id
assert db.run_async_session(
lambda session: User.async_get_by_name(session, "mp-test-user")
).id == created.id
assert db.run_async_session(
lambda session: User.async_get_by_id(session, created.id)
).id == created.id
assert db.run_async_session(lambda session: User.async_get_by_name(session, "mp-test-user")).id == created.id
assert db.run_async_session(lambda session: User.async_get_by_id(session, created.id)).id == created.id
def test_user_lookup_returns_none_when_absent(db):
@@ -210,10 +208,8 @@ def test_user_async_mutations_match_sync_behaviour(db):
db.add(User(name="mp-test-async-otp", hashed_password="x", is_otp=False))
oper = UserOper()
assert asyncio.run(oper.async_update_otp_by_name(
name="mp-test-async-otp", otp=True, secret="S2")) is True
assert asyncio.run(oper.async_update_otp_by_name(
name="mp-test-nobody", otp=True, secret="S2")) is False
assert asyncio.run(oper.async_update_otp_by_name(name="mp-test-async-otp", otp=True, secret="S2")) is True
assert asyncio.run(oper.async_update_otp_by_name(name="mp-test-nobody", otp=True, secret="S2")) is False
assert asyncio.run(oper.async_delete_by_name(name="mp-test-async-otp")) is True
assert User.get_by_name(db.session, "mp-test-async-otp") is None
@@ -222,14 +218,50 @@ def test_user_async_mutations_match_sync_behaviour(db):
assert User.get_by_id(db.session, async_id_user.id) is None
def test_legacy_user_oper_delete_cascades_user_children(db):
"""旧 UserOper 删除入口保持可用,并由数据库清除配置和 PassKey。"""
user = db.add(User(name="legacy-delete", is_active=True))
user_id = user.id
username = user.name
db.add(
UserConfig(username=username, key="theme", value="dark"),
_passkey(user_id, "legacy-delete-credential"),
)
assert asyncio.run(UserOper().async_delete_by_name(username)) is True
db.session.expire_all()
assert User.get_by_id(db.session, user_id) is None
assert UserConfig.get_by_key(db.session, username, "theme") is None
assert PassKey.get_by_credential_id(db.session, "legacy-delete-credential") is None
# --------------------------------------------------------------------------- #
# PassKey
# --------------------------------------------------------------------------- #
def _passkey(user_id: int, credential_id: str, is_active: bool = True) -> PassKey:
"""构造一条 PassKey 记录。"""
return PassKey(user_id=user_id, credential_id=credential_id,
public_key="pk", sign_count=0, is_active=is_active)
return PassKey(user_id=user_id, credential_id=credential_id, public_key="pk", sign_count=0, is_active=is_active)
@pytest.fixture(autouse=True)
def _create_passkey_owners(request, db) -> None:
"""PassKey 查询用例必须使用受外键保护的真实用户主体。"""
if not request.node.name.startswith("test_passkey_"):
return
db.add(
*[
User(
id=user_id,
name=f"passkey-owner-{user_id}",
is_active=True,
is_superuser=False,
)
for user_id in range(9001, 9012)
]
)
def test_passkey_listing_excludes_inactive_credentials(db):
@@ -238,18 +270,19 @@ def test_passkey_listing_excludes_inactive_credentials(db):
停用的凭据仍能被列出意味着它还会出现在登录选项里等于停用没生效
"""
db.add(_passkey(9001, "cred-active-1"),
_passkey(9001, "cred-active-2"),
_passkey(9001, "cred-inactive", is_active=False),
_passkey(9002, "cred-other"))
db.add(
_passkey(9001, "cred-active-1"),
_passkey(9001, "cred-active-2"),
_passkey(9001, "cred-inactive", is_active=False),
_passkey(9002, "cred-other"),
)
listed = PassKey.get_by_user_id(db.session, 9001)
assert {p.credential_id for p in listed} == {"cred-active-1", "cred-active-2"}
assert {p.credential_id for p in db.run_async_session(
lambda session: PassKey.async_get_by_user_id(session, 9001)
)} == \
{"cred-active-1", "cred-active-2"}
assert {
p.credential_id for p in db.run_async_session(lambda session: PassKey.async_get_by_user_id(session, 9001))
} == {"cred-active-1", "cred-active-2"}
def test_passkey_oper_queries_use_explicit_session(db, monkeypatch):
@@ -279,9 +312,7 @@ def test_passkey_lookup_by_credential_id_skips_inactive(db):
assert PassKey.get_by_credential_id(db.session, "cred-live").user_id == 9003
assert PassKey.get_by_credential_id(db.session, "cred-dead") is None
assert db.run_async_session(
lambda session: PassKey.async_get_by_credential_id(session, "cred-dead")
) is None
assert db.run_async_session(lambda session: PassKey.async_get_by_credential_id(session, "cred-dead")) is None
def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
@@ -290,9 +321,7 @@ def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
monkeypatch.setattr(
db_base,
"run_sync_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外同步事务")
),
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外同步事务")),
)
assert PassKey.get_by_id(db.session, key.id).credential_id == "cred-explicit"
@@ -302,18 +331,22 @@ def test_passkey_remaining_queries_reuse_explicit_sessions(db, monkeypatch):
monkeypatch.setattr(
db_base,
"run_async_transaction",
lambda _operation: (_ for _ in ()).throw(
AssertionError("不应创建额外异步事务")
),
lambda _operation: (_ for _ in ()).throw(AssertionError("不应创建额外异步事务")),
)
assert [
item.credential_id
for item in await PassKey.async_get_by_user_id(
session,
9008,
)
] == ["cred-explicit"]
assert (
await PassKey.async_get_by_credential_id(
session,
"cred-explicit",
)
is not None
)
assert [item.credential_id for item in await PassKey.async_get_by_user_id(
session,
9008,
)] == ["cred-explicit"]
assert await PassKey.async_get_by_credential_id(
session,
"cred-explicit",
) is not None
assert await PassKey.async_get_by_id(session, key.id) is not None
asyncio.run(check())
@@ -326,9 +359,7 @@ def test_passkey_get_by_id_ignores_active_flag(db):
dead = db.add(_passkey(9004, "cred-admin", is_active=False))
assert PassKey.get_by_id(db.session, dead.id).credential_id == "cred-admin"
assert db.run_async_session(
lambda session: PassKey.async_get_by_id(session, dead.id)
).credential_id == "cred-admin"
assert db.run_async_session(lambda session: PassKey.async_get_by_id(session, dead.id)).credential_id == "cred-admin"
def test_passkey_delete_requires_matching_owner(db):
@@ -367,3 +398,80 @@ def test_passkey_update_last_used_persists_sign_count(db):
assert key.update_last_used(db.session, sign_count=42) is True
assert PassKey.get_by_id(db.session, key.id).sign_count == 42
def test_passkey_oper_sign_count_compare_and_swap_has_single_winner(db):
"""两个基于同一旧计数的认证提交只能有一个更新成功。"""
key = db.add(_passkey(9008, "cred-cas"))
key.sign_count = 41
db.session.flush()
oper = PassKeyOper(db.session)
assert (
oper.compare_and_update_sign_count(
passkey_id=key.id,
expected_sign_count=41,
sign_count=42,
)
is True
)
assert (
oper.compare_and_update_sign_count(
passkey_id=key.id,
expected_sign_count=41,
sign_count=43,
)
is False
)
db.session.expire_all()
assert PassKey.get_by_id(db.session, key.id).sign_count == 42
def test_passkey_oper_sign_count_cas_rejects_inactive_or_regressed_key(db):
"""停用凭证及未递增的非零计数都不得被认证提交覆盖。"""
inactive = db.add(_passkey(9009, "cred-cas-inactive", is_active=False))
active = db.add(_passkey(9010, "cred-cas-regressed"))
active.sign_count = 5
db.session.flush()
oper = PassKeyOper(db.session)
assert (
oper.compare_and_update_sign_count(
passkey_id=inactive.id,
expected_sign_count=0,
sign_count=1,
)
is False
)
assert (
oper.compare_and_update_sign_count(
passkey_id=active.id,
expected_sign_count=5,
sign_count=4,
)
is False
)
assert (
oper.compare_and_update_sign_count(
passkey_id=active.id,
expected_sign_count=5,
sign_count=5,
)
is False
)
def test_passkey_oper_sign_count_cas_allows_counterless_authenticator(db):
"""不支持签名计数器的认证器允许按 WebAuthn 约定保持零计数。"""
key = db.add(_passkey(9011, "cred-cas-counterless"))
oper = PassKeyOper(db.session)
assert (
oper.compare_and_update_sign_count(
passkey_id=key.id,
expected_sign_count=0,
sign_count=0,
)
is True
)
+109 -46
View File
@@ -5,6 +5,7 @@ Oper 层大多是模型方法的薄封装,但薄封装恰恰是最容易出错
默认值漏传聚合逻辑写在这一层这些都绕过了模型侧的测试这里对着真实数据库
验证 Oper 的对外契约而不是验证它调了哪个模型方法
"""
import asyncio
import importlib
from unittest.mock import Mock
@@ -48,14 +49,26 @@ def test_oper_with_explicit_session_does_not_commit_caller_transaction(db, monke
@pytest.fixture(autouse=True)
def _track(db):
"""把本文件涉及的表纳入用例级回收。"""
db.watermark(Site, SiteIcon, SiteStatistic, SiteUserData, PluginData, Workflow,
User, UserConfig, MediaServerItem, DownloadHistory, DownloadFiles)
db.watermark(
Site,
SiteIcon,
SiteStatistic,
SiteUserData,
PluginData,
Workflow,
User,
UserConfig,
MediaServerItem,
DownloadHistory,
DownloadFiles,
)
# --------------------------------------------------------------------------- #
# SiteOper
# --------------------------------------------------------------------------- #
def _site_kwargs(name: str, domain: str, **extra) -> dict:
"""构造新增站点的参数。"""
return dict(name=name, domain=domain, url=f"https://{domain}/", **extra)
@@ -184,8 +197,7 @@ def test_site_oper_userdata_readers(db):
assert any(r.domain == "op-read.test" for r in oper.get_userdata())
assert any(r.domain == "op-read.test" for r in oper.get_userdata_by_date(today))
assert any(r.domain == "op-read.test" for r in oper.get_userdata_latest())
assert [r.domain for r in asyncio.run(
oper.async_get_userdata_by_domain("op-read.test"))] == ["op-read.test"]
assert [r.domain for r in asyncio.run(oper.async_get_userdata_by_domain("op-read.test"))] == ["op-read.test"]
def test_site_oper_update_icon_creates_then_only_overwrites_with_content(db):
@@ -256,8 +268,7 @@ def test_site_oper_success_caps_the_timing_note_at_ten_entries(db):
只靠循环调用无法触及上限分支
"""
old_note = {f"2026-08-13 10:00:{index:02d}": index + 1 for index in range(10)}
db.add(SiteStatistic(domain="op-cap.test", success=10, fail=0, seconds=5,
lst_state=0, note=old_note))
db.add(SiteStatistic(domain="op-cap.test", success=10, fail=0, seconds=5, lst_state=0, note=old_note))
SiteOper(db=db.session).success("op-cap.test", seconds=99)
@@ -298,6 +309,7 @@ def test_site_oper_async_success_and_fail_match_sync(db):
# PluginDataOper
# --------------------------------------------------------------------------- #
def test_plugindata_oper_save_is_upsert(db):
"""
同一键重复保存走更新而不是新增
@@ -359,10 +371,20 @@ def test_plugindata_oper_async_accessors_match_sync(db):
# WorkflowOper
# --------------------------------------------------------------------------- #
def _workflow_kwargs(name: str, **extra) -> dict:
"""构造新增工作流的参数。"""
return dict(name=name, description=name, timer="0 * * * *", state="W",
actions=[], flows=[], context={}, execution_state={}, **extra)
return dict(
name=name,
description=name,
timer="0 * * * *",
state="W",
actions=[],
flows=[],
context={},
execution_state={},
**extra,
)
def test_workflow_oper_add_rejects_duplicate_name(db):
@@ -430,6 +452,7 @@ def test_workflow_oper_event_list_and_async_accessors(db):
# UserOper / UserConfigOper
# --------------------------------------------------------------------------- #
def test_user_oper_reads_permissions_and_settings(db):
"""
权限与个性化设置的读取在用户不存在时各有约定的空值
@@ -437,8 +460,7 @@ def test_user_oper_reads_permissions_and_settings(db):
权限返回 {} 而设置返回 None上层据此区分没有权限没有这个用户
"""
oper = UserOper(db=db.session)
oper.add(name="op-user", hashed_password="x",
permissions={"discovery": True}, settings={"theme": "dark"})
oper.add(name="op-user", hashed_password="x", permissions={"discovery": True}, settings={"theme": "dark"})
assert oper.get_by_name("op-user").name == "op-user"
assert oper.get_permissions("op-user") == {"discovery": True}
@@ -471,6 +493,7 @@ def test_userconfig_oper_set_get_and_delete_on_empty_value(db):
空值删除是恢复默认的实现方式退化成写入空串会让默认值再也拿不回来
"""
db.add(User(name="op-cfg-user", is_active=True))
oper = UserConfigOper()
oper.set("op-cfg-user", "theme", "dark")
@@ -486,6 +509,10 @@ def test_userconfig_oper_scopes_cache_by_username(db):
"""
内存缓存必须按用户名隔离且用户名为空时返回全量缓存
"""
db.add(
User(name="op-cfg-a", is_active=True),
User(name="op-cfg-b", is_active=True),
)
oper = UserConfigOper()
oper.set("op-cfg-a", "theme", "dark")
oper.set("op-cfg-b", "theme", "light")
@@ -501,10 +528,19 @@ def test_userconfig_oper_scopes_cache_by_username(db):
# MediaServerOper
# --------------------------------------------------------------------------- #
def _server_item(item_id: str, **extra) -> dict:
"""构造媒体服务器条目的写入参数。"""
payload = dict(server="emby", library="lib", item_id=item_id, item_type="电影",
title="片名", year="2026", media_source=TMDB, media_id="5001")
payload = dict(
server="emby",
library="lib",
item_id=item_id,
item_type="电影",
title="片名",
year="2026",
media_source=TMDB,
media_id="5001",
)
payload.update(extra)
return payload
@@ -554,17 +590,13 @@ def test_mediaserver_oper_exists_checks_season_presence(db):
季信息缺失却判为已入库会让整季订阅被跳过
"""
oper = MediaServerOper(db=db.session)
oper.add(**_server_item("ms-season", media_id="5200", item_type="电视剧",
seasoninfo={"1": [1, 2]}))
oper.add(**_server_item("ms-season", media_id="5200", item_type="电视剧", seasoninfo={"1": [1, 2]}))
assert oper.exists(media_source=TMDB, media_id="5200", mtype="电视剧",
season="1") is not None
assert oper.exists(media_source=TMDB, media_id="5200", mtype="电视剧",
season="2") is None
assert oper.exists(media_source=TMDB, media_id="5200", mtype="电视剧", season="1") is not None
assert oper.exists(media_source=TMDB, media_id="5200", mtype="电视剧", season="2") is None
oper.add(**_server_item("ms-noseason", media_id="5300", item_type="电视剧"))
assert oper.exists(media_source=TMDB, media_id="5300", mtype="电视剧",
season="1") is None
assert oper.exists(media_source=TMDB, media_id="5300", mtype="电视剧", season="1") is None
def test_mediaserver_oper_get_item_id_and_async_twins(db):
@@ -577,8 +609,7 @@ def test_mediaserver_oper_get_item_id_and_async_twins(db):
assert oper.get_item_id(media_source=TMDB, media_id="5400", mtype="电影") == "ms-id"
assert oper.get_item_id(media_source=TMDB, media_id="5999", mtype="电影") is None
assert asyncio.run(oper.async_get_item_id(
media_source=TMDB, media_id="5400", mtype="电影")) == "ms-id"
assert asyncio.run(oper.async_get_item_id(media_source=TMDB, media_id="5400", mtype="电影")) == "ms-id"
assert asyncio.run(oper.async_exists(title="片名", mtype="电影", year="2026")) is not None
@@ -602,6 +633,7 @@ def test_mediaserver_oper_cleanup_entry_points(db):
# DownloadHistoryOper
# --------------------------------------------------------------------------- #
def test_downloadhistory_oper_get_by_hashes_returns_a_mapping(db):
"""
批量查询返回hash -> 历史映射供上层直接按 hash 取用
@@ -609,10 +641,8 @@ def test_downloadhistory_oper_get_by_hashes_returns_a_mapping(db):
上层拿到列表还要自己配对正是 N+1 的温床这里的契约是映射
"""
oper = DownloadHistoryOper(db=db.session)
oper.add(path="/downloads/a", type=MediaType.TV.value, title="A",
download_hash="oh-a", date="2026-08-13 10:00:00")
oper.add(path="/downloads/b", type=MediaType.TV.value, title="B",
download_hash="oh-b", date="2026-08-13 10:00:00")
oper.add(path="/downloads/a", type=MediaType.TV.value, title="A", download_hash="oh-a", date="2026-08-13 10:00:00")
oper.add(path="/downloads/b", type=MediaType.TV.value, title="B", download_hash="oh-b", date="2026-08-13 10:00:00")
mapping = oper.get_by_hashes(["oh-a", "oh-b", "oh-missing"])
@@ -626,12 +656,28 @@ def test_downloadhistory_oper_file_entry_points(db):
文件记录的写入与四个读取入口构成完整闭环删除只置状态
"""
oper = DownloadHistoryOper(db=db.session)
oper.add_files([
dict(downloader="qb", download_hash="oh-f", fullpath="/downloads/f/a.mkv",
savepath="/downloads/f", filepath="a.mkv", torrentname="种子", state=1),
dict(downloader="qb", download_hash="oh-f", fullpath="/downloads/f/b.mkv",
savepath="/downloads/f", filepath="b.mkv", torrentname="种子", state=1),
])
oper.add_files(
[
dict(
downloader="qb",
download_hash="oh-f",
fullpath="/downloads/f/a.mkv",
savepath="/downloads/f",
filepath="a.mkv",
torrentname="种子",
state=1,
),
dict(
downloader="qb",
download_hash="oh-f",
fullpath="/downloads/f/b.mkv",
savepath="/downloads/f",
filepath="b.mkv",
torrentname="种子",
state=1,
),
]
)
assert len(oper.get_files_by_hash("oh-f")) == 2
assert len(oper.get_files_by_hash("oh-f", state=1)) == 2
@@ -651,20 +697,27 @@ def test_downloadhistory_oper_query_entry_points(db):
路径hash媒体身份分页与时间窗口五个查询入口都应透传生效
"""
oper = DownloadHistoryOper(db=db.session)
oper.add(path="/downloads/q", type=MediaType.TV.value, title="Q", year="2026",
media_source=TMDB, media_id="4001", seasons="S01",
download_hash="oh-q", username="alice", date="2026-08-13 10:00:00")
oper.add(
path="/downloads/q",
type=MediaType.TV.value,
title="Q",
year="2026",
media_source=TMDB,
media_id="4001",
seasons="S01",
download_hash="oh-q",
username="alice",
date="2026-08-13 10:00:00",
)
assert oper.get_by_path("/downloads/q").title == "Q"
assert oper.get_by_hash("oh-q").title == "Q"
assert len(oper.get_by_media_identity(media_source=TMDB, media_id="4001")) == 1
assert oper.list_by_page(page=1, count=1)[0].title == "Q"
assert [h.title for h in oper.list_by_user_date("2026-08-20", username="alice")] == ["Q"]
assert [h.title for h in oper.list_by_date("2026-08-01", MediaType.TV.value,
TMDB, "4001", "S01")] == ["Q"]
assert [h.title for h in oper.list_by_date("2026-08-01", MediaType.TV.value, TMDB, "4001", "S01")] == ["Q"]
assert [h.title for h in oper.list_by_type(MediaType.TV.value, days=36500)] == ["Q"]
assert [h.title for h in oper.get_last_by(mtype=MediaType.TV.value,
media_source=TMDB, media_id="4001")] == ["Q"]
assert [h.title for h in oper.get_last_by(mtype=MediaType.TV.value, media_source=TMDB, media_id="4001")] == ["Q"]
def test_downloadhistory_oper_delete_entry_points(db):
@@ -672,12 +725,21 @@ def test_downloadhistory_oper_delete_entry_points(db):
历史与文件记录的删除入口都应真正落库
"""
oper = DownloadHistoryOper(db=db.session)
oper.add(path="/downloads/d", type=MediaType.TV.value, title="D",
download_hash="oh-d", date="2026-08-13 10:00:00")
oper.add(path="/downloads/d", type=MediaType.TV.value, title="D", download_hash="oh-d", date="2026-08-13 10:00:00")
history = oper.get_by_hash("oh-d")
oper.add_files([dict(downloader="qb", download_hash="oh-d",
fullpath="/downloads/d/a.mkv", savepath="/downloads/d",
filepath="a.mkv", torrentname="种子", state=1)])
oper.add_files(
[
dict(
downloader="qb",
download_hash="oh-d",
fullpath="/downloads/d/a.mkv",
savepath="/downloads/d",
filepath="a.mkv",
torrentname="种子",
state=1,
)
]
)
file_row = oper.get_file_by_fullpath("/downloads/d/a.mkv")
oper.delete_downloadfile(file_row.id)
@@ -692,8 +754,9 @@ def test_downloadhistory_oper_async_delete(db):
异步删除历史与同步等效
"""
oper = DownloadHistoryOper(db=db.session)
oper.add(path="/downloads/ad", type=MediaType.TV.value, title="AD",
download_hash="oh-ad", date="2026-08-13 10:00:00")
oper.add(
path="/downloads/ad", type=MediaType.TV.value, title="AD", download_hash="oh-ad", date="2026-08-13 10:00:00"
)
history = oper.get_by_hash("oh-ad")
db.session.commit()
+28 -16
View File
@@ -1,3 +1,4 @@
from collections.abc import Iterator
from dataclasses import asdict
from pathlib import Path
from types import SimpleNamespace
@@ -10,6 +11,7 @@ from app.application.download.failures import (
DownloadFailureSnapshot,
DownloadFailureWrite,
)
from app.application.history import DownloadHistorySnapshot
from app.chain.download import DownloadChain
from app.domain.context import Context, MediaInfo, SubtitleInfo, TorrentInfo
from app.domain.metainfo import MetaInfo
@@ -21,6 +23,19 @@ from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import MediaSource, MediaType
@pytest.fixture(autouse=True)
def _restore_eventmanager_instance_override() -> Iterator[None]:
"""每个用例后恢复事件单例实例属性,避免遮蔽后续类级 monkeypatch。"""
instance = download_module.eventmanager
marker = object()
original = vars(instance).get("send_event", marker)
yield
if original is marker:
vars(instance).pop("send_event", None)
else:
vars(instance)["send_event"] = original
@pytest.fixture(autouse=True)
def _mock_tmdb_supplement(monkeypatch):
"""隔离下载用例中的 TMDB 辅助识别外部边界。"""
@@ -41,10 +56,8 @@ class _FakeDownloadHistoryOper:
避免单元测试写入真实下载历史只验证下载链路的控制流
"""
def add(self, **_kwargs):
pass
def add_files(self, _files):
def add(self, _history, _files=()):
"""忽略当前用例无需验证的类型化历史写入。"""
pass
@@ -266,13 +279,9 @@ def test_download_single_persists_custom_words_snapshot(monkeypatch):
class _CapturingDownloadHistoryOper:
"""捕获写入下载历史的字段,验证识别词快照确实落库。"""
def add(self, **kwargs):
def add(self, history, _files=()):
"""捕获下载历史字段。"""
captured.update(kwargs)
def add_files(self, _files):
"""忽略与当前断言无关的下载文件记录。"""
pass
captured.update(history.to_payload())
_FakeThreadHelper.submitted = []
monkeypatch.setattr(
@@ -1283,13 +1292,15 @@ def test_downloading_includes_media_type_and_source_site(monkeypatch):
正在下载任务应从下载历史回填媒体类型和来源站点
"""
torrent = DownloaderTorrent(hash="download-hash", title="Demo.Release")
history = SimpleNamespace(
history = DownloadHistorySnapshot(
id=1,
path="/downloads/Demo.Release.mkv",
episodes="E02",
image="https://images.example.com/backdrop.jpg",
poster="https://images.example.com/poster.jpg",
seasons="S01",
title="示例剧集",
media_source=MediaSource.TMDB.value,
media_source=MediaSource.TMDB,
media_id="1001",
torrent_site="示例站点",
type="电视剧",
@@ -1307,10 +1318,11 @@ def test_downloading_includes_media_type_and_source_site(monkeypatch):
result = chain.downloading(name="qb-main")
assert result == [torrent]
assert torrent.media["type"] == "电视剧"
assert torrent.media["image"] == "https://images.example.com/poster.jpg"
assert torrent.media["poster"] == "https://images.example.com/poster.jpg"
assert torrent.media["backdrop"] == "https://images.example.com/backdrop.jpg"
assert torrent.media is not None
assert torrent.media.type == "电视剧"
assert torrent.media.image == "https://images.example.com/poster.jpg"
assert torrent.media.poster == "https://images.example.com/poster.jpg"
assert torrent.media.backdrop == "https://images.example.com/backdrop.jpg"
assert torrent.site_name == "示例站点"
assert torrent.userid == "user-1"
assert torrent.username == "tester"
+13 -9
View File
@@ -1,24 +1,27 @@
"""下载任务应用服务测试。"""
from types import SimpleNamespace
from app.application.download.tasks import DownloadTaskService
from app.application.history import DownloadHistorySnapshot
from app.schemas.transfer import DownloaderTorrent
from app.schemas.types import MediaSource
def test_download_task_service_enriches_history_and_controls_task():
"""下载任务查询应附加历史媒体信息,控制方法只转发规范参数。"""
torrent = SimpleNamespace(hash="hash", media=None)
history = SimpleNamespace(
media_source="tmdb",
torrent = DownloaderTorrent(hash="hash")
history = DownloadHistorySnapshot(
id=1,
path="/downloads/test",
media_source=MediaSource.TMDB,
media_id="123",
type="电影",
title="测试电影",
seasons=[1],
episodes=[2],
seasons="1",
episodes="2",
poster="poster",
image="backdrop",
torrent_site="站点",
userid=1,
userid="1",
username="alice",
)
calls = []
@@ -31,7 +34,8 @@ def test_download_task_service_enriches_history_and_controls_task():
)
assert service.downloading("qb") == [torrent]
assert torrent.media["media_id"] == "123"
assert torrent.media is not None
assert torrent.media.media_id == "123"
assert torrent.username == "alice"
assert service.set_downloading("hash", "start", "qb") is True
assert service.set_downloading("hash", "stop", "qb") is True
+30
View File
@@ -164,6 +164,36 @@ def test_broadcast_dispatch_uses_subscription_snapshot(isolated_eventmanager):
assert calls == ["mutating", "late"]
def test_strict_broadcast_waits_and_propagates_handler_failure(
isolated_eventmanager,
monkeypatch,
):
"""durable 广播不入队,并在真实 handler 失败时阻止调用方结算。"""
isolated_eventmanager._EventManager__lifecycle_state = "running"
calls = []
def handler(event):
"""记录稳定键后模拟真实 handler 失败。"""
calls.append(event.event_data["idempotency_key"])
raise RuntimeError("delivery failed")
monkeypatch.setattr(
isolated_eventmanager,
"_EventManager__handle_event_error",
lambda *_args, **_kwargs: None,
)
isolated_eventmanager.add_event_listener(EventType.ConfigChanged, handler)
with pytest.raises(RuntimeError, match="delivery failed"):
isolated_eventmanager.send_event_strict(
EventType.ConfigChanged,
{"idempotency_key": "config.changed:v1"},
)
assert calls == ["config.changed:v1"]
assert isolated_eventmanager._EventManager__event_queue.empty()
def test_sync_chain_dispatch_uses_subscription_snapshot(isolated_eventmanager):
"""同步链式事件中的订阅变更不影响当前处理器序列。"""
calls = []
+9 -7
View File
@@ -12,15 +12,17 @@ ensure_optional_stub("psutil")
ensure_optional_stub("dateparser")
ensure_optional_stub("Pinyin2Hanzi", is_pinyin=lambda value: False)
from app.modules.feishu import FeishuModule
from app.modules.feishu.feishu import Feishu
from app.schemas import Message
from app.schemas.message import (
ChannelCapability,
ChannelCapabilityManager,
from app.modules.feishu import FeishuModule # noqa: E402
from app.modules.feishu.feishu import Feishu # noqa: E402
from app.schemas.message import ( # noqa: E402
Message,
MessageResponse,
)
from app.schemas.types import NotificationChannel, MessageType
from app.schemas.notification import ( # noqa: E402
ChannelCapability,
ChannelCapabilityManager,
)
from app.schemas.types import MessageType, NotificationChannel # noqa: E402
class TestFeishu(unittest.TestCase):
+17 -2
View File
@@ -87,8 +87,22 @@ class _Outbox:
async def stage(self, intent, now) -> None:
"""模拟暂存 durable intent。"""
async def complete_by_event_key(self, event_key, completed_at) -> None:
"""模拟收口 durable intent。"""
class _DispatchStore:
"""提供订阅即时副作用所需的独立派发存储替身。"""
async def claim_by_event_key(self, event_key, now, lease_until):
"""模拟未取得指定消息 lease。"""
return None
async def complete(self, message_id, attempt, completed_at) -> bool:
"""模拟按 attempt 完成消息。"""
return True
async def retry(self, message_id, attempt, **kwargs) -> bool:
"""模拟按 attempt 释放消息。"""
return True
class _RuntimeSettings:
@@ -150,6 +164,7 @@ def _runtime() -> HostRuntime:
history_repository=_Repository,
transaction=_UnitOfWork,
outbox=_Outbox,
dispatch_store=_DispatchStore(),
),
workflow=WorkflowRuntime(
query=SimpleNamespace(),
+13 -1
View File
@@ -7,11 +7,23 @@ from webauthn.helpers.exceptions import InvalidRegistrationResponse
from app.api.endpoints import mfa as mfa_endpoint
from app.application.security import passkey as passkey_helper
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyChallengeStore,
PassKeyHelper,
PassKeyRegistrationOriginMismatchError,
PassKeyRegistrationVerificationError,
PasskeyChallengeStore,
configure_passkey_challenge_cache,
)
from app.runtime.cache import TTLCache
def setup_function():
"""为注册错误路径显式装配隔离的 challenge 缓存。"""
configure_passkey_challenge_cache(TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
))
def _registration_request(user_id: int = 1) -> mfa_endpoint.PassKeyRegistrationFinish:
+161 -3
View File
@@ -1,5 +1,5 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
import pytest
from fastapi import HTTPException
@@ -7,7 +7,12 @@ from starlette.requests import Request
from starlette.responses import Response
from app.api.endpoints import mfa as mfa_endpoint
from app.application.security.passkey import PasskeyChallengeStore
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyChallengeStore,
configure_passkey_challenge_cache,
)
from app.runtime.cache import TTLCache
def _request() -> Request:
@@ -25,7 +30,46 @@ def _request() -> Request:
def setup_function():
PasskeyChallengeStore._cache.clear()
configure_passkey_challenge_cache(TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
))
@pytest.mark.asyncio
async def test_mfa_status_hides_missing_disabled_and_unconfigured_accounts():
"""匿名状态查询不得用状态码、消息或数据区分非 OTP 账号。"""
users = [
None,
SimpleNamespace(is_active=False, is_otp=True),
SimpleNamespace(is_active=True, is_otp=False),
]
responses = []
for user in users:
service = SimpleNamespace(get_by_name=AsyncMock(return_value=user))
response = await mfa_endpoint.mfa_status("candidate", service=service)
responses.append(response.model_dump())
assert responses == [
{"success": True, "message": "", "data": {"enabled": False}},
] * len(users)
@pytest.mark.asyncio
async def test_mfa_status_reports_otp_only_for_active_account():
"""启用账号已配置 OTP 时仍应允许登录流程进入二次验证。"""
service = SimpleNamespace(
get_by_name=AsyncMock(
return_value=SimpleNamespace(is_active=True, is_otp=True)
)
)
response = await mfa_endpoint.mfa_status("candidate", service=service)
assert response.success is True
assert response.data == {"enabled": True}
def test_registration_transaction_is_bound_to_current_user():
@@ -186,3 +230,117 @@ def test_authentication_finish_token_cannot_be_replayed():
assert result.access_token == "access-token"
assert replay_error.value.status_code == 401
assert replay_error.value.detail == "认证请求已失效"
def test_authentication_finish_fails_closed_when_challenge_backend_fails(
monkeypatch,
):
"""challenge 后端故障不得继续查询凭证或签发 Token。"""
class FailingCache:
"""模拟 challenge 原子领取期间缓存不可用。"""
def consume(self, _key):
"""报告后端不可用。"""
raise RuntimeError("cache unavailable")
monkeypatch.setattr(PasskeyChallengeStore, "_cache", FailingCache())
service = SimpleNamespace(get_by_credential_id=Mock())
auth_service = SimpleNamespace(build_token_response=Mock())
with patch.object(
mfa_endpoint,
"get_configured_auth_service",
return_value=auth_service,
), patch.object(
mfa_endpoint,
"set_or_refresh_resource_token_cookie",
) as set_cookie, pytest.raises(HTTPException) as exc_info:
mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=mfa_endpoint.PassKeyAuthenticationFinish(
credential={"id": "credential-id"},
transaction_token="transaction-token",
),
service=service,
)
assert exc_info.value.status_code == 401
service.get_by_credential_id.assert_not_called()
auth_service.build_token_response.assert_not_called()
set_cookie.assert_not_called()
@pytest.mark.parametrize("cas_failure", [False, RuntimeError("write failed")])
def test_authentication_finish_does_not_issue_token_when_sign_count_write_fails(
cas_failure,
):
"""签名计数 CAS 冲突或事务失败时,认证不得越过持久化门禁。"""
token = PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=1,
)
passkey_req = mfa_endpoint.PassKeyAuthenticationFinish(
credential={"id": "credential-id"},
transaction_token=token,
)
passkey = SimpleNamespace(
id=10,
user_id=1,
public_key="public-key",
sign_count=5,
)
user = SimpleNamespace(
id=1,
name="user",
is_active=True,
is_superuser=False,
)
compare_and_update = Mock()
if isinstance(cas_failure, Exception):
compare_and_update.side_effect = cas_failure
else:
compare_and_update.return_value = cas_failure
service = SimpleNamespace(
get_by_credential_id=Mock(return_value=passkey),
compare_and_update_sign_count=compare_and_update,
)
auth_service = SimpleNamespace(build_token_response=Mock())
with patch.object(
mfa_endpoint,
"_extract_and_standardize_credential_id",
return_value="credential-id",
), patch.object(
mfa_endpoint,
"get_configured_user_id_lookup",
return_value=Mock(return_value=user),
), patch.object(
mfa_endpoint.PassKeyHelper,
"verify_authentication_response",
return_value=(True, 6),
), patch.object(
mfa_endpoint,
"get_configured_auth_service",
return_value=auth_service,
), patch.object(
mfa_endpoint,
"set_or_refresh_resource_token_cookie",
) as set_cookie:
with pytest.raises(HTTPException) as exc_info:
mfa_endpoint.passkey_authenticate_finish(
request=_request(),
response=Response(),
passkey_req=passkey_req,
service=service,
)
assert exc_info.value.status_code == 401
compare_and_update.assert_called_once_with(
passkey_id=10,
expected_sign_count=5,
sign_count=6,
)
auth_service.build_token_response.assert_not_called()
set_cookie.assert_not_called()
+272 -12
View File
@@ -1,6 +1,8 @@
"""durable side-effect outbox 原子性、认领、重试与幂等测试。"""
"""durable side-effect outbox 原子性、认领、重试与稳定重放测试。"""
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timedelta, timezone
from threading import Barrier
from unittest.mock import MagicMock
import pytest
@@ -8,9 +10,17 @@ from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
from app.application.maintenance import CleanupPolicy, DataCleanupService
from app.application.outbox import ClaimedOutboxMessage, OutboxDispatcher, OutboxIntent
from app.application.outbox import (
ClaimedOutboxMessage,
OutboxDispatcher,
OutboxIntent,
OutboxLeaseLostError,
)
from app.application.subscription.write import CreateSubscriptionCommand
from app.db.adapters.outbox import SqlAlchemyOutboxRepository
from app.db.adapters.outbox import (
SqlAlchemyOutboxDispatchStore,
SqlAlchemyOutboxStager,
)
from app.db.base import Base
from app.db.maintenance import DatabaseCleanupRepository
from app.db.models.outbox import OutboxMessage
@@ -138,11 +148,32 @@ def test_dispatcher_marks_success_and_closes_owned_resource() -> None:
)
assert dispatcher.dispatch_one() is True
repository.complete.assert_called_once_with(7, now)
repository.complete.assert_called_once_with(7, 1, now)
dispatcher.close()
close.assert_called_once_with()
def test_dispatcher_raises_when_complete_loses_lease() -> None:
"""handler 成功但 complete fencing 失败时必须明确报告 lease 丢失。"""
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
repository = MagicMock()
message = ClaimedOutboxMessage(7, "key", "test", {}, 1, 1)
repository.claim.return_value = message
repository.complete.return_value = False
handler = MagicMock()
dispatcher = OutboxDispatcher(
repository,
{"test": handler},
clock=lambda: now,
)
with pytest.raises(OutboxLeaseLostError, match="完成凭证"):
dispatcher.dispatch_one()
handler.assert_called_once_with(message)
repository.retry.assert_not_called()
def test_sync_outbox_claim_is_exclusive_for_event_key() -> None:
"""同步投递与恢复投递竞争同一 intent 时只允许一个取得 lease。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
@@ -153,20 +184,19 @@ def test_sync_outbox_claim_is_exclusive_for_event_key() -> None:
event_key = "subscribe.complete:7:tmdb:123:v1"
with factory() as session:
repository = SqlAlchemyOutboxRepository(session)
repository = SqlAlchemyOutboxStager(session)
repository.stage(
OutboxIntent(event_key=event_key, topic="subscribe.complete", payload={}),
now,
)
session.commit()
with factory() as owner, factory() as competitor:
assert SqlAlchemyOutboxRepository(owner).claim_by_event_key(
event_key, now, lease_until
) is True
assert SqlAlchemyOutboxRepository(competitor).claim_by_event_key(
event_key, now, lease_until
) is False
store = SqlAlchemyOutboxDispatchStore(factory)
owner = store.claim_by_event_key(event_key, now, lease_until)
competitor = store.claim_by_event_key(event_key, now, lease_until)
assert owner is not None
assert owner.attempt == 1
assert competitor is None
with factory() as session:
message = session.execute(select(OutboxMessage)).scalar_one()
@@ -175,6 +205,236 @@ def test_sync_outbox_claim_is_exclusive_for_event_key() -> None:
assert message.lease_until == lease_until.isoformat()
def test_concurrent_claim_allows_exactly_one_owner(tmp_path) -> None:
"""两个独立 dispatcher 并发竞争同一消息时只允许一个取得 lease。"""
engine = create_engine(f"sqlite+pysqlite:///{tmp_path / 'outbox.db'}")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
with factory() as session:
SqlAlchemyOutboxStager(session).stage(
OutboxIntent(event_key="race:v1", topic="test", payload={}),
now,
)
session.commit()
barrier = Barrier(2)
def claim():
"""同时开始一次独立短事务认领。"""
barrier.wait()
return SqlAlchemyOutboxDispatchStore(factory).claim_by_event_key(
"race:v1",
now,
now + timedelta(seconds=60),
)
with ThreadPoolExecutor(max_workers=2) as executor:
claimed = list(executor.map(lambda _index: claim(), range(2)))
owners = [message for message in claimed if message is not None]
assert len(owners) == 1
assert owners[0].attempt == 1
engine.dispose()
def test_expired_owner_cannot_settle_new_attempt() -> None:
"""lease 过期后的旧 owner 不得覆盖新 attempt 的完成或重试状态。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
with factory() as session:
SqlAlchemyOutboxStager(session).stage(
OutboxIntent(event_key="fenced:v1", topic="test", payload={}),
now,
)
session.commit()
store = SqlAlchemyOutboxDispatchStore(factory)
first = store.claim_by_event_key(
"fenced:v1",
now,
now + timedelta(seconds=1),
)
second_now = now + timedelta(seconds=2)
second = store.claim_by_event_key(
"fenced:v1",
second_now,
second_now + timedelta(seconds=60),
)
assert first is not None
assert second is not None
assert second.attempt == first.attempt + 1
assert store.complete(first.message_id, first.attempt, second_now) is False
assert store.retry(
first.message_id,
first.attempt,
next_retry_at=second_now,
last_error="stale owner",
dead=False,
) is False
assert store.complete(second.message_id, second.attempt, second_now) is True
def test_handler_replays_with_stable_key_after_success_before_complete_crash() -> None:
"""外部成功后 complete 前崩溃会按稳定键至少再次投递一次。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
event_key = "external-effect:v1"
with factory() as session:
SqlAlchemyOutboxStager(session).stage(
OutboxIntent(event_key=event_key, topic="external", payload={}),
now,
)
session.commit()
store = SqlAlchemyOutboxDispatchStore(factory)
first = store.claim(now, now + timedelta(seconds=1))
assert first is not None
external_results: list[str] = []
def handler(message: ClaimedOutboxMessage) -> None:
"""记录 at-least-once 外部效果及其稳定幂等键。"""
assert message.payload["idempotency_key"] == message.event_key
external_results.append(message.event_key)
handler(first)
dispatcher = OutboxDispatcher(
store,
{"external": handler},
clock=lambda: now + timedelta(seconds=2),
)
assert dispatcher.dispatch_one() is True
assert external_results == [event_key, event_key]
with factory() as session:
persisted = session.execute(select(OutboxMessage)).scalar_one()
assert persisted.status == "completed"
assert persisted.attempt == 2
@pytest.mark.parametrize("handler_kind", ["event", "notification"])
def test_startup_handler_replays_strict_boundary_with_stable_key(
handler_kind,
monkeypatch,
) -> None:
"""真实 startup handler 等待执行边界,并以同一键诚实重放。"""
from app.command import CommandChain
from app.runtime.events import EventManager
from app.startup.initializers.modules import _build_outbox_handlers
calls = []
if handler_kind == "event":
topic = "subscribe.added"
payload = {"subscribe_id": 7}
monkeypatch.setattr(
EventManager,
"send_event_strict",
lambda _self, _etype, data: calls.append(data["idempotency_key"]),
)
else:
topic = "subscribe.complete.notification"
payload = {"message": {"title": "完成", "text": "Test"}}
monkeypatch.setattr(
CommandChain,
"post_message_strict",
lambda _self, _message, *, event_key: calls.append(event_key),
)
handlers = _build_outbox_handlers()
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
now = datetime(2026, 8, 28, tzinfo=timezone.utc)
event_key = f"startup:{handler_kind}:v1"
with factory() as session:
SqlAlchemyOutboxStager(session).stage(
OutboxIntent(event_key=event_key, topic=topic, payload=payload),
now,
)
session.commit()
store = SqlAlchemyOutboxDispatchStore(factory)
first = store.claim(now, now + timedelta(seconds=1))
assert first is not None
handlers[topic](first)
dispatcher = OutboxDispatcher(
store,
handlers,
clock=lambda: now + timedelta(seconds=2),
)
assert dispatcher.dispatch_one() is True
assert calls == [event_key, event_key]
def test_strict_notification_preserves_legacy_provider_signature(monkeypatch) -> None:
"""durable 通知只传既有 message 参数,并在调用上下文携带稳定键。"""
from app.command import CommandChain
from app.runtime.correlation import get_correlation_id
from app.schemas.message import Message
chain = CommandChain()
received = []
def legacy_provider(message) -> None:
"""模拟只接受旧式单参数签名的第三方通知 provider。"""
received.append((message, get_correlation_id()))
monkeypatch.setattr(chain.eventmanager, "send_event", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
chain,
"run_module_strict",
lambda method, **kwargs: legacy_provider(**kwargs),
)
chain.post_message_strict(
Message(title="完成", text="Test", save_history=False),
event_key="subscribe.complete:7:notification",
)
assert len(received) == 1
assert received[0][0].source is None
assert received[0][1] == "subscribe.complete:7:notification"
def test_strict_notification_retry_writes_history_once(monkeypatch) -> None:
"""provider 失败后按稳定键重试,历史只写一次而渠道继续 at-least-once。"""
from app.command import CommandChain
from app.schemas.message import Message
chain = CommandChain()
history_sources = set()
provider_sources = []
monkeypatch.setattr(chain.eventmanager, "send_event", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
chain.messageoper,
"exists_by_source",
lambda source: source in history_sources,
)
monkeypatch.setattr(
chain.messageoper,
"add",
lambda **payload: history_sources.add(payload["source"]),
)
def deliver(_method, *, message) -> None:
"""第一次模拟外部失败,第二次成功,并记录 provider 实际路由 source。"""
provider_sources.append(message.source)
if len(provider_sources) == 1:
raise RuntimeError("temporary")
monkeypatch.setattr(chain, "run_module_strict", deliver)
message = Message(title="完成", text="Test")
with pytest.raises(RuntimeError, match="temporary"):
chain.post_message_strict(message, event_key="subscribe.complete:7:notification")
chain.post_message_strict(message, event_key="subscribe.complete:7:notification")
assert history_sources == {"outbox:subscribe.complete:7:notification"}
assert provider_sources == [None, None]
def test_outbox_cleanup_removes_only_expired_terminal_history_in_batches() -> None:
"""清理只删除超过各自保留期的终态记录,并按批次持续收口。"""
engine = create_engine("sqlite+pysqlite:///:memory:")
+60 -2
View File
@@ -1,11 +1,21 @@
from concurrent.futures import ThreadPoolExecutor
import pytest
from app.application.security.passkey import (
PASSKEY_CHALLENGE_TTL_SECONDS,
PasskeyChallengeStore,
configure_passkey_challenge_cache,
)
from app.runtime.cache import TTLCache
from app.application.security.passkey import PasskeyChallengeStore
def setup_function():
PasskeyChallengeStore._cache.clear()
configure_passkey_challenge_cache(TTLCache(
region="passkey_challenge",
maxsize=4096,
ttl=PASSKEY_CHALLENGE_TTL_SECONDS,
))
def test_challenge_can_only_be_consumed_once():
@@ -90,3 +100,51 @@ def test_concurrent_consumers_have_single_winner():
results = list(executor.map(lambda _: consume(), range(8)))
assert sum(result is not None for result in results) == 1
def test_challenge_store_requires_explicit_cache(monkeypatch):
"""未完成启动装配时不得签发一个实际未保存的事务 token。"""
monkeypatch.setattr(PasskeyChallengeStore, "_cache", None)
with pytest.raises(RuntimeError, match="缓存尚未配置"):
PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=None,
)
@pytest.mark.parametrize("operation", ["store", "consume"])
def test_challenge_cache_failure_is_not_treated_as_a_cache_miss(
monkeypatch,
operation,
):
"""安全缓存故障必须向认证入口传播,不能伪装成正常 miss。"""
class FailingCache:
"""模拟严格缓存写入或领取故障。"""
def store(self, _key, _value):
"""按用例模拟写入结果。"""
if operation == "store":
raise RuntimeError("cache unavailable")
def consume(self, _key):
"""按用例模拟领取结果。"""
if operation == "consume":
raise RuntimeError("cache unavailable")
return None
monkeypatch.setattr(PasskeyChallengeStore, "_cache", FailingCache())
with pytest.raises(RuntimeError, match="cache unavailable"):
if operation == "store":
PasskeyChallengeStore.issue(
challenge="server-challenge",
purpose="authentication",
user_id=None,
)
else:
PasskeyChallengeStore.consume(
transaction_token="transaction-token",
purpose="authentication",
)
+1 -4
View File
@@ -186,10 +186,7 @@ def test_default_sync_writer_persists_once_and_reuses_duplicate(db) -> None:
media_id="arch-221-sync",
)
assert [row.id for row in rows] == [first[0]]
assert after_commit.call_args_list == [
((first[0],), {}),
((first[0],), {}),
]
assert after_commit.call_args_list == [((first[0],), {})]
def test_default_sync_writer_keeps_failed_report_pending_without_raising(db) -> None:
+67 -16
View File
@@ -5,11 +5,12 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from sqlalchemy.orm import Session
from app.application.outbox import ClaimedOutboxMessage
from app.application.subscription.delete import (
DeleteSubscribeCommand,
SyncDeleteSubscribeCommand,
SubscribeDeletionActor,
SubscribeDeletionCandidate,
SyncDeleteSubscribeCommand,
)
from app.db.models.subscribe import Subscribe
from app.db.oper.subscribe import SubscribeOper
@@ -69,9 +70,27 @@ class _Outbox:
if self.stage_error:
raise self.stage_error
async def complete_by_event_key(self, event_key, _completed_at):
"""记录即时事件成功后的 intent 收口"""
self.calls.append(("outbox_complete", event_key))
async def claim_by_event_key(self, event_key, _now, _lease_until):
"""记录并返回当前测试拥有的异步 lease"""
self.calls.append(("outbox_claim", event_key))
return ClaimedOutboxMessage(
message_id=len(self.calls),
event_key=event_key,
topic="test",
payload={},
payload_version=1,
attempt=1,
)
async def complete(self, message_id, attempt, _completed_at):
"""记录带 attempt fencing 的异步完成。"""
self.calls.append(("outbox_complete", message_id, attempt))
return True
async def retry(self, message_id, attempt, **_kwargs):
"""记录带 attempt fencing 的异步重试。"""
self.calls.append(("outbox_retry", message_id, attempt))
return True
class _SyncRepository:
@@ -125,9 +144,27 @@ class _SyncOutbox:
"""记录同步暂存的 intent。"""
self.calls.append(("outbox_stage", intent))
def complete_by_event_key(self, event_key, _completed_at):
"""记录同步完成的 intent"""
self.calls.append(("outbox_complete", event_key))
def claim_by_event_key(self, event_key, _now, _lease_until):
"""记录并返回当前测试拥有的同步 lease"""
self.calls.append(("outbox_claim", event_key))
return ClaimedOutboxMessage(
message_id=len(self.calls),
event_key=event_key,
topic="test",
payload={},
payload_version=1,
attempt=1,
)
def complete(self, message_id, attempt, _completed_at):
"""记录带 attempt fencing 的同步完成。"""
self.calls.append(("outbox_complete", message_id, attempt))
return True
def retry(self, message_id, attempt, **_kwargs):
"""记录带 attempt fencing 的同步重试。"""
self.calls.append(("outbox_retry", message_id, attempt))
return True
def _candidate(username="alice"):
@@ -175,6 +212,7 @@ def _command(
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
dispatch_store=outbox,
)
@@ -197,6 +235,7 @@ def _async_report_command(candidate, calls, result=True, error=None, outbox=None
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
dispatch_store=outbox,
)
@@ -224,6 +263,7 @@ def _sync_command(
publish_deleted=publish,
report_deleted=report,
outbox=outbox,
dispatch_store=outbox,
)
@@ -242,7 +282,9 @@ async def test_owner_delete_commits_before_event_and_report():
assert [call[0] for call in calls] == ["get", "delete", "commit", "event", "report"]
assert calls[3][2]["subscribe_info"] == _candidate().event_payload
assert calls[3][2]["idempotency_key"].startswith("subscribe.deleted:7:")
assert calls[4][1] == _candidate().event_payload
report_payload = dict(calls[4][1])
assert report_payload.pop("idempotency_key").endswith(":report")
assert report_payload == _candidate().event_payload
@pytest.mark.asyncio
@@ -357,8 +399,10 @@ async def test_delete_stages_outbox_before_commit_and_completes_after_event():
"outbox_stage",
"outbox_stage",
"commit",
"outbox_claim",
"event",
"outbox_complete",
"outbox_claim",
"report",
"outbox_complete",
]
@@ -366,8 +410,8 @@ async def test_delete_stages_outbox_before_commit_and_completes_after_event():
assert intent.topic == "subscribe.deleted"
report_intent = calls[3][1]
assert report_intent.topic == "subscribe.deleted.report"
assert intent.event_key == calls[5][2]["idempotency_key"]
assert calls[6][1] == intent.event_key
assert intent.event_key == calls[6][2]["idempotency_key"]
assert calls[5][1] == intent.event_key
assert calls[8][1] == report_intent.event_key
@@ -408,7 +452,8 @@ async def test_async_reporter_completes_report_intent_only_after_confirmation():
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report", "outbox_complete",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_complete",
]
@@ -425,7 +470,8 @@ async def test_async_reporter_false_keeps_report_intent_pending():
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_retry",
]
@@ -447,7 +493,8 @@ async def test_async_reporter_error_keeps_report_intent_pending():
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_retry",
]
@@ -505,7 +552,8 @@ def test_sync_delete_uses_same_durable_effect_order():
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report", "outbox_complete",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_complete",
]
assert calls[2][1].topic == "subscribe.deleted"
assert calls[3][1].topic == "subscribe.deleted.report"
@@ -527,9 +575,12 @@ def test_sync_delete_report_failure_returns_success_and_keeps_intent_pending():
) is True
assert [call[0] for call in calls] == [
"get", "delete", "outbox_stage", "outbox_stage", "commit",
"event", "outbox_complete", "report",
"outbox_claim", "event", "outbox_complete", "outbox_claim",
"report", "outbox_retry",
]
assert calls[7][1] == _candidate().event_payload
report_payload = dict(calls[9][1])
assert report_payload.pop("idempotency_key").endswith(":report")
assert report_payload == _candidate().event_payload
@pytest.mark.parametrize("failure", ["delete", "commit"])
+35 -10
View File
@@ -4,6 +4,7 @@ from datetime import datetime
import pytest
from app.application.outbox import ClaimedOutboxMessage
from app.application.subscription.complete import CompleteSubscriptionCommand
@@ -54,14 +55,29 @@ class _Outbox:
"""记录 durable intent。"""
self.calls.append(("stage", intent))
def claim_by_event_key(self, event_key: str, _now: datetime, _lease_until: datetime) -> bool:
def claim_by_event_key(self, event_key: str, _now: datetime, _lease_until: datetime):
"""记录同步投递认领结果。"""
self.calls.append(("claim", event_key))
return self.claim_result
if not self.claim_result:
return None
return ClaimedOutboxMessage(
message_id=len(self.calls),
event_key=event_key,
topic="test",
payload={},
payload_version=1,
attempt=1,
)
def complete_by_event_key(self, event_key: str, _now: datetime) -> None:
def complete(self, message_id: int, attempt: int, _now: datetime) -> bool:
"""记录成功副作用对应的 intent 收口。"""
self.calls.append(("complete", event_key))
self.calls.append(("complete", message_id, attempt))
return True
def retry(self, message_id: int, attempt: int, **_kwargs) -> bool:
"""记录失败副作用对应的 intent 释放。"""
self.calls.append(("retry", message_id, attempt))
return True
def _command(
@@ -93,10 +109,12 @@ def _command(
raise report_error
return report_result
outbox = _Outbox(calls, claim_result)
return CompleteSubscriptionCommand(
repository=_Repository(calls),
unit_of_work=_UnitOfWork(calls),
outbox=_Outbox(calls, claim_result),
outbox=outbox,
dispatch_store=outbox,
publish=publish,
), notify, report
@@ -128,7 +146,9 @@ def test_completion_stages_business_and_independent_intents_before_commit(failur
if failure == "notify":
assert [call[0] for call in calls[5:]] == ["notify"]
elif failure == "event":
assert [call[0] for call in calls[5:]] == ["notify", "claim", "event"]
assert [call[0] for call in calls[5:]] == [
"notify", "claim", "event", "retry",
]
def test_completion_report_failure_returns_success_and_keeps_intent_pending():
@@ -146,7 +166,7 @@ def test_completion_report_failure_returns_success_and_keeps_intent_pending():
assert [call[0] for call in calls] == [
"history", "delete", "stage", "stage", "commit",
"notify", "claim", "event", "complete", "claim", "report",
"notify", "claim", "event", "complete", "claim", "report", "retry",
]
@@ -168,7 +188,7 @@ def test_completion_report_error_returns_success_and_keeps_intent_pending():
assert [call[0] for call in calls] == [
"history", "delete", "stage", "stage", "commit",
"notify", "claim", "event", "complete", "claim", "report",
"notify", "claim", "event", "complete", "claim", "report", "retry",
]
@@ -215,8 +235,13 @@ def test_completion_stages_and_closes_notification_snapshot() -> None:
"subscribe.complete.report",
]
assert staged[1].payload["message"]["title"] == "完成"
completed = [call[1] for call in calls if call[0] == "complete"]
assert completed[0].endswith(":notification")
completed = [call for call in calls if call[0] == "complete"]
notification_claim = next(
call for call in calls
if call[0] == "claim" and call[1].endswith(":notification")
)
assert completed[0][1] > 0
assert notification_claim[1].endswith(":notification")
def test_completion_skips_sync_delivery_owned_by_outbox_dispatcher() -> None:
+30 -6
View File
@@ -2,6 +2,7 @@
import pytest
from app.application.outbox import ClaimedOutboxMessage
from app.application.subscription.mutation import (
SubscriptionActor,
SubscriptionMutationService,
@@ -84,9 +85,27 @@ class _Outbox:
if self.stage_error:
raise self.stage_error
async def complete_by_event_key(self, event_key: str, _completed_at) -> None:
"""记录即时事件成功后的完成键"""
self.calls.append(("outbox_complete", event_key))
async def claim_by_event_key(self, event_key, _now, _lease_until):
"""记录并返回当前测试拥有的派发 lease"""
self.calls.append(("outbox_claim", event_key))
return ClaimedOutboxMessage(
message_id=7,
event_key=event_key,
topic="subscribe.modified",
payload={},
payload_version=1,
attempt=1,
)
async def complete(self, message_id, attempt, _completed_at):
"""记录带 attempt fencing 的完成结算。"""
self.calls.append(("outbox_complete", message_id, attempt))
return True
async def retry(self, message_id, attempt, **_kwargs):
"""记录带 attempt fencing 的失败释放。"""
self.calls.append(("outbox_retry", message_id, attempt))
return True
def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
@@ -99,10 +118,12 @@ def _service(calls: list, *, event_error: Exception | None = None, outbox=None):
if event_error:
raise event_error
outbox = outbox or _Outbox(calls)
return SubscriptionMutationService(
repository=_Repository(subscribe, calls),
unit_of_work=_UnitOfWork(calls),
outbox=outbox or _Outbox(calls),
outbox=outbox,
dispatch_store=outbox,
publish_modified=publish,
)
@@ -129,14 +150,15 @@ async def test_modified_event_is_staged_with_update_and_completed_after_publish(
"stage_update",
"outbox_stage",
"commit",
"outbox_claim",
"event",
"outbox_complete",
]
intent = calls[2][1]
assert intent.topic == "subscribe.modified"
assert intent.event_key.startswith("subscribe.modified:7:update:")
assert calls[4][1]["idempotency_key"] == intent.event_key
assert calls[5][1] == intent.event_key
assert calls[5][1]["idempotency_key"] == intent.event_key
assert calls[6][1:] == (7, 1)
@pytest.mark.asyncio
@@ -181,5 +203,7 @@ async def test_modified_event_failure_keeps_committed_intent_pending():
"stage_update",
"outbox_stage",
"commit",
"outbox_claim",
"event",
"outbox_retry",
]
@@ -0,0 +1,196 @@
"""下载历史类型化适配器的投影与事务测试。"""
import asyncio
import pytest
from app.application.history import (
DownloadFileSnapshot,
DownloadFileWrite,
DownloadHistorySnapshot,
DownloadHistoryWrite,
)
from app.db.adapters.history.download import (
SessionDownloadHistoryRepository,
TransactionalDownloadHistoryRepository,
)
from app.db.models.downloadhistory import DownloadHistory
from app.db.session import SessionFactory, async_session_scope
from app.db.uow import SqlAlchemyUnitOfWork
from app.schemas.types import MediaSource, MediaType
def _repository() -> TransactionalDownloadHistoryRepository:
"""构造绑定测试数据库短 Session 的下载历史仓储。"""
return TransactionalDownloadHistoryRepository(
sync_session=SessionFactory,
async_session=async_session_scope,
)
def _history_write(
*,
download_hash: str = "typed-history-hash",
) -> DownloadHistoryWrite:
"""构造覆盖 Chain 消费字段的类型化下载历史写入。"""
return DownloadHistoryWrite(
path="/downloads/Typed.Show.S01",
type=MediaType.TV.value,
title="Typed Show",
year="2026",
media_source=MediaSource.TMDB,
media_id="7001",
music_type=None,
seasons="S01",
episodes="E01-E02",
image="https://example.test/backdrop.jpg",
poster="https://example.test/poster.jpg",
downloader="qb",
download_hash=download_hash,
torrent_name="Typed Show torrent",
torrent_description="description",
torrent_site="Example",
userid=7,
username="alice",
channel="telegram",
date="2026-08-28 10:00:00",
note={
"source": "subscribe",
"nested": {"season": 1, "episodes": [1, 2]},
},
media_category="剧集",
episode_group="group-1",
custom_words="S02 => S01",
)
def _file_write(
*,
download_hash: str = "typed-history-hash",
) -> DownloadFileWrite:
"""构造与类型化历史关联的下载文件写入。"""
return DownloadFileWrite(
downloader="qb",
download_hash=download_hash,
fullpath="/downloads/Typed.Show.S01/Episode01.mkv",
savepath="/downloads/Typed.Show.S01",
filepath="Episode01.mkv",
torrentname="Typed Show torrent",
)
def test_transactional_repository_projects_detached_snapshots(db) -> None:
"""所有同步查询都应在 Session 内投影,且 JSON 不与后续查询共享。"""
repository = _repository()
history_id = repository.add(_history_write(), (_file_write(),))
by_hash = repository.get_by_hash("typed-history-hash")
by_path = repository.get_by_path("/downloads/Typed.Show.S01")
by_hashes = repository.get_by_hashes(["typed-history-hash"])
by_identity = repository.get_by_media_identity(
MediaSource.TMDB,
"7001",
)
by_fullpath = repository.get_file_by_fullpath("/downloads/Typed.Show.S01/Episode01.mkv")
by_file_hash = repository.get_files_by_hash(
"typed-history-hash",
state=1,
)
by_savepath = repository.get_files_by_savepath("/downloads/Typed.Show.S01")
assert isinstance(by_hash, DownloadHistorySnapshot)
assert by_hash.id == history_id
assert by_hash.userid == "7"
assert by_hash.media_source == MediaSource.TMDB
assert by_path == by_hash
assert by_hashes == {"typed-history-hash": by_hash}
assert by_identity == [by_hash]
assert isinstance(by_fullpath, DownloadFileSnapshot)
assert by_file_hash == [by_fullpath]
assert by_savepath == [by_fullpath]
assert not hasattr(by_hash, "_sa_instance_state")
assert not hasattr(by_fullpath, "_sa_instance_state")
assert isinstance(by_hash.note, dict)
with pytest.raises(TypeError, match="不可修改"):
by_hash.note["source"] = "mutated"
nested = by_hash.note["nested"]
assert isinstance(nested, dict)
with pytest.raises(TypeError, match="不可修改"):
nested["season"] = 2
episodes = nested["episodes"]
assert isinstance(episodes, list)
with pytest.raises(TypeError, match="不可修改"):
episodes.append(3)
refreshed = repository.get_by_hash("typed-history-hash")
assert refreshed is not None
assert refreshed.note == {
"source": "subscribe",
"nested": {"season": 1, "episodes": [1, 2]},
}
def test_transactional_repository_rolls_back_history_and_files_on_commit_failure(
db,
monkeypatch,
) -> None:
"""历史与文件提交失败时必须整体回滚,不得留下半写入记录。"""
repository = _repository()
download_hash = "typed-rollback-hash"
def fail_commit(_unit_of_work) -> None:
"""模拟数据库提交失败。"""
raise RuntimeError("commit failed")
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
repository.add(
_history_write(download_hash=download_hash),
(_file_write(download_hash=download_hash),),
)
assert repository.get_by_hash(download_hash) is None
assert repository.get_files_by_hash(download_hash) == []
def test_transactional_repository_async_query_and_delete(db) -> None:
"""异步分页返回脱离 Session 的快照,删除由独立事务提交。"""
repository = _repository()
history_id = repository.add(_history_write(download_hash="typed-async-hash"))
async def exercise() -> list[DownloadHistorySnapshot]:
"""在同一事件循环中执行异步分页和删除。"""
records = await repository.async_list_by_page(count=10)
await repository.async_delete(history_id)
return records
records = asyncio.run(exercise())
assert any(record.id == history_id for record in records)
assert all(not hasattr(record, "_sa_instance_state") for record in records)
assert repository.get_by_hash("typed-async-hash") is None
def test_session_repository_obeys_caller_transaction(db) -> None:
"""请求级 adapter 只暂存变更,提交与回滚由调用方 UoW 决定。"""
history = db.add(
DownloadHistory(
path="/downloads/request-history",
type=MediaType.MOVIE.value,
title="Request History",
download_hash="request-history-hash",
)
)
with SessionFactory() as session:
repository = SessionDownloadHistoryRepository(session)
repository.stage_delete_history(history.id)
session.rollback()
assert _repository().get_by_hash("request-history-hash") is not None
with SessionFactory() as session:
repository = SessionDownloadHistoryRepository(session)
repository.stage_delete_history(history.id)
session.commit()
assert _repository().get_by_hash("request-history-hash") is None
@@ -64,7 +64,7 @@ def test_resolve_download_history_falls_back_to_parent_download_path():
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/season-pack/Test.Show.S01E01.mkv"),
)
@@ -85,7 +85,7 @@ def test_resolve_download_history_falls_back_to_unique_savepath_hash():
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/season-pack/subs/Test.Show.S01E01.zh.ass"),
)
@@ -108,7 +108,7 @@ def test_resolve_download_history_skips_ambiguous_savepath_hashes():
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/shared/Test.Show.S01E01.mkv"),
)
@@ -128,7 +128,7 @@ def test_resolve_download_history_stops_at_shared_download_root_path(monkeypatch
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
@@ -156,7 +156,7 @@ def test_resolve_download_history_stops_at_shared_download_root_savepath(monkeyp
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
@@ -184,7 +184,7 @@ def test_resolve_download_history_accepts_shared_root_savepath_for_exact_file(mo
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/Ghost.Concert.mkv"),
)
@@ -215,7 +215,7 @@ def test_resolve_download_history_stops_at_type_category_download_root(monkeypat
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/电视剧/动漫/Ghost.Concert.mkv"),
)
@@ -301,7 +301,7 @@ def test_resolve_download_history_stops_at_nested_category_root(monkeypatch):
)
history = _make_chain()._resolve_download_history(
downloadhis=oper,
repository=oper,
file_path=Path("/downloads/动漫/日本番剧/Ghost.Concert.mkv"),
)
+12 -10
View File
@@ -1,15 +1,16 @@
from dataclasses import replace
from types import SimpleNamespace
import pytest
from app.application.history import DownloadHistorySnapshot
from app.application.transfer.workflow import TransferTask
from app.chain.transfer import TransferChain
from app.domain.context import MediaInfo
from app.domain.meta.metabase import MetaBase
from app.runtime.config import settings
from app.schemas.file import FileItem
from app.schemas.history import DownloadHistory
from app.schemas.types import MediaType
from app.schemas.types import MediaSource, MediaType
def _make_chain() -> TransferChain:
@@ -59,17 +60,18 @@ def _make_file_meta(year: str = "2013") -> _FileMeta:
return _FileMeta(year=year)
def _make_history() -> SimpleNamespace:
def _make_history() -> DownloadHistorySnapshot:
"""构造被合集首部电影占用的下载历史。"""
return SimpleNamespace(
return DownloadHistorySnapshot(
id=1,
path="/downloads/The.Hunger.Games.Complete.4-Film.Collection",
download_hash="collection-hash",
downloader="qbittorrent",
type=MediaType.MOVIE.value,
title="饥饿游戏",
year="2012",
tmdbid=70160,
doubanid=None,
media_source=MediaSource.TMDB,
media_id="70160",
episode_group=None,
media_category=None,
username=None,
@@ -82,12 +84,12 @@ def test_movie_year_conflict_only_applies_to_movies():
"""仅电影年份冲突应触发逐文件识别,电视剧季包仍复用下载历史。"""
file_meta = _make_file_meta()
movie_history = _make_history()
tv_history = SimpleNamespace(type=MediaType.TV, year="2012")
tv_history = replace(movie_history, type=MediaType.TV.value)
assert TransferChain._is_movie_year_conflict(file_meta, movie_history)
assert not TransferChain._is_movie_year_conflict(file_meta, tv_history)
movie_history.year = "2013"
assert not TransferChain._is_movie_year_conflict(file_meta, movie_history)
same_year_history = replace(movie_history, year="2013")
assert not TransferChain._is_movie_year_conflict(file_meta, same_year_history)
def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch):
@@ -136,7 +138,7 @@ def test_conflicting_download_history_recognizes_movie_by_file_meta(monkeypatch)
size=1024,
),
meta=_make_file_meta(),
download_history=DownloadHistory(**vars(_make_history())),
download_history=_make_history(),
preview=True,
)
+83
View File
@@ -0,0 +1,83 @@
"""用户端点的稳定业务错误映射测试。"""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from app.api.endpoints.user import create_user, delete_user_by_id, update_user
from app.application.security.user import (
LastActiveSuperuserError,
UserNameConflictError,
)
@pytest.mark.asyncio
async def test_create_user_maps_database_name_conflict() -> None:
"""创建竞态触发唯一约束时必须返回既有业务响应。"""
service = SimpleNamespace(
create=AsyncMock(side_effect=UserNameConflictError("duplicate"))
)
user_input = SimpleNamespace(model_dump=lambda: {
"name": "duplicate",
"password": None,
})
response = await create_user(
service=service,
user_in=user_input,
current_user=SimpleNamespace(),
)
assert response.success is False
assert response.message == "用户已存在"
@pytest.mark.asyncio
async def test_update_user_maps_name_and_last_admin_conflicts() -> None:
"""改名冲突和最后管理员保护必须保持稳定可读响应。"""
user_input = SimpleNamespace(model_dump=lambda: {
"id": 7,
"name": "renamed",
"password": None,
})
service = SimpleNamespace(
get_by_id=AsyncMock(return_value=SimpleNamespace(id=7)),
update=AsyncMock(side_effect=UserNameConflictError("renamed")),
)
response = await update_user(
service=service,
user_in=user_input,
current_user=SimpleNamespace(),
)
assert response.success is False
assert response.message == "用户名已被使用"
service.update.side_effect = LastActiveSuperuserError("admin")
response = await update_user(
service=service,
user_in=user_input,
current_user=SimpleNamespace(),
)
assert response.success is False
assert response.message == "必须保留至少一个启用的超级管理员"
@pytest.mark.asyncio
async def test_delete_user_maps_last_admin_conflict() -> None:
"""删除最后管理员时必须返回业务失败且不伪报成功。"""
service = SimpleNamespace(
get_by_id=AsyncMock(return_value=SimpleNamespace(id=7)),
delete=AsyncMock(side_effect=LastActiveSuperuserError("admin")),
)
response = await delete_user_by_id(
service=service,
user_id=7,
current_user=SimpleNamespace(),
)
assert response.success is False
assert response.message == "必须保留至少一个启用的超级管理员"
+361
View File
@@ -0,0 +1,361 @@
"""用户名唯一性 Alembic 迁移测试。"""
import importlib
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy.dialects import postgresql
from sqlalchemy.exc import IntegrityError
from sqlalchemy.schema import CreateTable
from app.db.engine import _register_sqlite_foreign_keys
from app.db.models.passkey import PassKey
from app.db.models.user import User
from app.db.models.userconfig import UserConfig
MIGRATION_MODULE = "database.versions.a9d4f2c7e6b1_3_0_18"
def _bind_migration(monkeypatch, connection):
"""把用户名迁移绑定到隔离数据库连接。"""
migration = importlib.import_module(MIGRATION_MODULE)
monkeypatch.setattr(
migration,
"op",
Operations(MigrationContext.configure(connection)),
)
return migration
def _legacy_metadata() -> tuple[sa.MetaData, sa.Table, sa.Table]:
"""构造允许重名且带用户外键的旧版最小表结构。"""
metadata = sa.MetaData()
users = sa.Table(
"user",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=True),
sa.Index("ix_user_name", "name"),
)
passkeys = sa.Table(
"passkey",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("user.id"),
nullable=False,
),
)
return metadata, users, passkeys
def _user_rows(connection, users: sa.Table) -> list[dict]:
"""按稳定身份返回用户快照。"""
return list(connection.execute(sa.select(users).order_by(users.c.id)).mappings())
def _indexes(connection) -> dict[str, dict]:
"""返回用户表的命名索引。"""
return {index["name"]: index for index in sa.inspect(connection).get_indexes("user")}
def test_user_model_declares_exact_unique_name_index() -> None:
"""当前用户聚合模型必须声明唯一身份和精确级联约束。"""
indexes = {index.name: index for index in User.__table__.indexes}
assert "ix_user_name" not in indexes
assert indexes["ux_user_name"].unique is True
assert tuple(column.name for column in indexes["ux_user_name"].columns) == ("name",)
user_config_fk = next(iter(UserConfig.__table__.foreign_keys))
assert user_config_fk.target_fullname == "user.name"
assert user_config_fk.onupdate == "CASCADE"
assert user_config_fk.ondelete == "CASCADE"
assert UserConfig.__table__.c.username.nullable is False
assert UserConfig.__table__.c.key.nullable is False
constraints = {constraint.name: constraint for constraint in UserConfig.__table__.constraints}
assert {column.name for column in constraints["uq_userconfig_username_key"].columns} == {"username", "key"}
passkey_fk = next(iter(PassKey.__table__.foreign_keys))
assert passkey_fk.target_fullname == "user.id"
assert passkey_fk.ondelete == "CASCADE"
def test_sqlite_connection_registration_enables_foreign_keys() -> None:
"""每条宿主 SQLite 连接都必须实际启用外键检查。"""
engine = sa.create_engine("sqlite://")
_register_sqlite_foreign_keys(engine)
with engine.connect() as connection:
assert connection.exec_driver_sql("PRAGMA foreign_keys").scalar_one() == 1
engine.dispose()
def test_user_child_constraints_compile_for_postgresql() -> None:
"""用户从属约束必须生成 PostgreSQL 可执行的级联 DDL。"""
user_config_ddl = str(
CreateTable(UserConfig.__table__).compile(
dialect=postgresql.dialect(),
)
)
passkey_ddl = str(
CreateTable(PassKey.__table__).compile(
dialect=postgresql.dialect(),
)
)
assert ('FOREIGN KEY(username) REFERENCES "user" (name) ON DELETE CASCADE ON UPDATE CASCADE') in user_config_ddl
assert ('FOREIGN KEY(user_id) REFERENCES "user" (id) ON DELETE CASCADE') in passkey_ddl
def test_user_child_migration_repairs_data_and_cascades(monkeypatch) -> None:
"""迁移清债后配置和 PassKey 不得脱离用户主体,并保持可逆。"""
metadata = sa.MetaData()
users = sa.Table(
"user",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("is_active", sa.Boolean()),
sa.Index("ix_user_name", "name"),
)
configs = sa.Table(
"userconfig",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("username", sa.String(), nullable=True),
sa.Column("key", sa.String(), nullable=True),
sa.Column("value", sa.JSON()),
)
passkeys = sa.Table(
"passkey",
metadata,
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("user.id"),
nullable=False,
),
)
engine = sa.create_engine("sqlite://")
with engine.connect() as connection:
metadata.create_all(connection)
connection.execute(
users.insert().values(
id=1,
name="old",
is_active=True,
)
)
connection.execute(
configs.insert(),
[
{"id": 1, "username": "old", "key": "theme", "value": "first"},
{"id": 2, "username": "old", "key": "theme", "value": "duplicate"},
{"id": 3, "username": "ghost", "key": "theme", "value": "orphan"},
{"id": 4, "username": None, "key": "theme", "value": "null-user"},
{"id": 5, "username": "old", "key": None, "value": "null-key"},
],
)
connection.execute(
passkeys.insert(),
[
{"id": 11, "user_id": 1},
{"id": 12, "user_id": 999},
],
)
connection.commit()
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
connection.commit()
migration.upgrade()
connection.commit()
assert connection.execute(sa.select(configs.c.id, configs.c.username, configs.c.key)).all() == [
(1, "old", "theme")
]
assert connection.execute(sa.select(passkeys.c.id)).scalars().all() == [11]
inspector = sa.inspect(connection)
config_columns = {column["name"]: column for column in inspector.get_columns("userconfig")}
assert config_columns["username"]["nullable"] is False
assert config_columns["key"]["nullable"] is False
config_fk = inspector.get_foreign_keys("userconfig")[0]
assert config_fk["options"] == {
"ondelete": "CASCADE",
"onupdate": "CASCADE",
}
passkey_fk = inspector.get_foreign_keys("passkey")[0]
assert passkey_fk["options"] == {"ondelete": "CASCADE"}
unique = {item["name"]: item for item in inspector.get_unique_constraints("userconfig")}
assert unique["uq_userconfig_username_key"]["column_names"] == [
"username",
"key",
]
connection.exec_driver_sql("PRAGMA foreign_keys=ON")
connection.execute(users.update().values(name="new"))
assert connection.execute(sa.select(configs.c.username)).scalar_one() == "new"
with pytest.raises(IntegrityError):
with connection.begin_nested():
connection.execute(
configs.insert().values(
username="new",
key="theme",
value="duplicate",
)
)
with pytest.raises(IntegrityError):
with connection.begin_nested():
connection.execute(
configs.insert().values(
username="new",
key=None,
value="empty-key",
)
)
connection.execute(users.delete())
assert connection.execute(sa.select(configs.c.id)).all() == []
assert connection.execute(sa.select(passkeys.c.id)).all() == []
connection.commit()
migration.downgrade()
connection.commit()
downgraded = sa.inspect(connection)
columns = {column["name"]: column for column in downgraded.get_columns("userconfig")}
assert columns["username"]["nullable"] is True
assert columns["key"]["nullable"] is True
assert downgraded.get_foreign_keys("userconfig") == []
assert downgraded.get_foreign_keys("passkey")[0]["options"] == {}
engine.dispose()
def test_migration_preserves_rows_and_foreign_keys_across_replay(
monkeypatch,
) -> None:
"""升级应确定性修复重名并在降级、再升级时保留用户身份。"""
engine = sa.create_engine("sqlite://")
metadata, users, passkeys = _legacy_metadata()
with engine.begin() as connection:
connection.exec_driver_sql("PRAGMA foreign_keys = ON")
metadata.create_all(connection)
connection.execute(
users.insert(),
[
{"id": 1, "name": "alice", "is_active": True},
{"id": 2, "name": "alice", "is_active": True},
{"id": 3, "name": "alice", "is_active": None},
{
"id": 4,
"name": "alice__duplicate_2",
"is_active": True,
},
{"id": 5, "name": "bob", "is_active": True},
],
)
connection.execute(
passkeys.insert(),
[
{"id": 11, "user_id": 2},
{"id": 12, "user_id": 3},
],
)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
upgraded_rows = _user_rows(connection, users)
migration.upgrade()
assert _user_rows(connection, users) == upgraded_rows
assert upgraded_rows == [
{"id": 1, "name": "alice", "is_active": True},
{
"id": 2,
"name": "alice__duplicate_2_1",
"is_active": False,
},
{
"id": 3,
"name": "alice__duplicate_3",
"is_active": False,
},
{
"id": 4,
"name": "alice__duplicate_2",
"is_active": True,
},
{"id": 5, "name": "bob", "is_active": True},
]
assert connection.execute(sa.select(passkeys.c.id, passkeys.c.user_id).order_by(passkeys.c.id)).all() == [
(11, 2),
(12, 3),
]
index = _indexes(connection)["ux_user_name"]
assert tuple(index["column_names"]) == ("name",)
assert index["unique"] == 1
assert "ix_user_name" not in _indexes(connection)
with pytest.raises(IntegrityError):
with connection.begin_nested():
connection.execute(
users.insert().values(
id=6,
name="alice",
is_active=True,
)
)
migration.downgrade()
downgraded_rows = _user_rows(connection, users)
assert downgraded_rows == upgraded_rows
assert "ux_user_name" not in _indexes(connection)
assert tuple(_indexes(connection)["ix_user_name"]["column_names"]) == ("name",)
connection.execute(users.insert().values(id=6, name="alice", is_active=True))
migration.upgrade()
assert _user_rows(connection, users)[-1] == {
"id": 6,
"name": "alice__duplicate_6",
"is_active": False,
}
assert connection.execute(sa.select(passkeys.c.user_id).order_by(passkeys.c.id)).scalars().all() == [2, 3]
assert _indexes(connection)["ux_user_name"]["unique"] == 1
def test_migration_repairs_malformed_canonical_index(monkeypatch) -> None:
"""升级必须替换同名但列或唯一语义错误的部分迁移索引。"""
engine = sa.create_engine("sqlite://")
metadata, users, _passkeys = _legacy_metadata()
with engine.begin() as connection:
metadata.create_all(connection)
connection.execute(
users.insert(),
[{"id": 1, "name": "alice", "is_active": True}],
)
connection.exec_driver_sql('CREATE INDEX ux_user_name ON "user" (is_active)')
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
index = _indexes(connection)["ux_user_name"]
assert tuple(index["column_names"]) == ("name",)
assert index["unique"] == 1
def test_migration_accepts_fresh_current_schema(monkeypatch) -> None:
"""当前模型已建表时,重复升级不得创建冲突索引。"""
engine = sa.create_engine("sqlite://")
with engine.begin() as connection:
User.__table__.create(connection)
migration = _bind_migration(monkeypatch, connection)
migration.upgrade()
migration.upgrade()
index = _indexes(connection)["ux_user_name"]
assert tuple(index["column_names"]) == ("name",)
assert index["unique"] == 1
+321 -25
View File
@@ -1,16 +1,51 @@
"""用户冻结快照与短事务适配器测试。"""
import asyncio
import threading
from contextlib import asynccontextmanager
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.application.security.user import AuxiliaryUserCreate
from app.db.adapters.user import TransactionalUserRepository
from app.application.security.user import (
AuxiliaryUserCreate,
LastActiveSuperuserError,
UserNameConflictError,
UserService,
)
from app.application.security.userconfig import UserConfigurationService
from app.db.adapters.configuration import TransactionalUserConfigurationRepository
from app.db.adapters.user import SqlAlchemyUserRepository, TransactionalUserRepository
from app.db.engine import _register_sqlite_foreign_keys
from app.db.models.passkey import PassKey
from app.db.models.user import User
from app.db.uow import SqlAlchemyUnitOfWork
from app.db.models.userconfig import UserConfig
from app.db.oper.userconfig import UserConfigOper
from app.db.uow import SqlAlchemyAsyncUnitOfWork, SqlAlchemyUnitOfWork
from app.foundation.singleton import Singleton
class _InlineDatabaseExecutor:
"""在测试协程内执行短同步配置发布。"""
async def run(self, operation):
"""执行并返回同步操作结果。"""
return operation()
def _configuration_service(sync_factory) -> UserConfigurationService:
"""从当前测试数据库加载并返回用户配置发布服务。"""
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
snapshot = UserConfigOper()
repository = TransactionalUserConfigurationRepository(sync_factory, snapshot)
repository.load_snapshot()
return UserConfigurationService(
repository,
async_executor=_InlineDatabaseExecutor(),
)
@pytest.fixture
@@ -62,6 +97,27 @@ def _insert_user(sync_factory, **overrides) -> int:
return user.id
@asynccontextmanager
async def _user_write_context(database_path):
"""创建包含用户聚合表的异步请求会话。"""
engine = create_async_engine(f"sqlite+aiosqlite:///{database_path}")
sync_engine = create_engine(f"sqlite:///{database_path}")
_register_sqlite_foreign_keys(engine.sync_engine)
_register_sqlite_foreign_keys(sync_engine)
sync_factory = sessionmaker(bind=sync_engine, expire_on_commit=False)
async with engine.begin() as connection:
await connection.run_sync(User.__table__.create)
await connection.run_sync(UserConfig.__table__.create)
await connection.run_sync(PassKey.__table__.create)
factory = async_sessionmaker(bind=engine, expire_on_commit=False)
try:
async with factory() as session:
yield session, sync_factory
finally:
await engine.dispose()
sync_engine.dispose()
@pytest.mark.asyncio
async def test_user_snapshots_are_detached_and_deeply_frozen(user_repository) -> None:
"""会话关闭后公开与认证快照仍可读,嵌套 JSON 不可被调用方修改。"""
@@ -89,16 +145,16 @@ def test_auxiliary_create_commits_before_return(user_repository) -> None:
"""辅助认证创建成功返回时,新用户必须已对后续独立会话可见。"""
repository, sync_factory = user_repository
created = repository.create_auxiliary(AuxiliaryUserCreate(
name="created",
hashed_password="hash",
))
created = repository.create_auxiliary(
AuxiliaryUserCreate(
name="created",
hashed_password="hash",
)
)
assert created.name == "created"
with sync_factory() as session:
persisted = session.execute(
select(User).where(User.name == "created")
).scalar_one()
persisted = session.execute(select(User).where(User.name == "created")).scalar_one()
assert persisted.is_active is True
assert persisted.is_superuser is False
@@ -117,15 +173,15 @@ def test_auxiliary_create_rolls_back_commit_failure(
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
repository.create_auxiliary(AuxiliaryUserCreate(
name="rolled-back",
hashed_password="hash",
))
repository.create_auxiliary(
AuxiliaryUserCreate(
name="rolled-back",
hashed_password="hash",
)
)
with sync_factory() as session:
assert session.execute(
select(User).where(User.name == "rolled-back")
).scalar_one_or_none() is None
assert session.execute(select(User).where(User.name == "rolled-back")).scalar_one_or_none() is None
def test_channel_binding_requires_one_active_unambiguous_owner(user_repository) -> None:
@@ -155,11 +211,251 @@ def test_channel_binding_requires_all_supplied_identifiers(user_repository) -> N
settings={"feishu_userid": "u-1", "feishu_openid": "o-1"},
)
assert repository.find_name_by_bindings({
"feishu_userid": "u-1",
"feishu_openid": "o-1",
}) == "feishu-user"
assert repository.find_name_by_bindings({
"feishu_userid": "u-1",
"feishu_openid": "wrong",
}) is None
assert (
repository.find_name_by_bindings(
{
"feishu_userid": "u-1",
"feishu_openid": "o-1",
}
)
== "feishu-user"
)
assert (
repository.find_name_by_bindings(
{
"feishu_userid": "u-1",
"feishu_openid": "wrong",
}
)
is None
)
@pytest.mark.asyncio
async def test_user_rename_migrates_configuration_atomically(tmp_path) -> None:
"""改名必须通过数据库级联迁移旧偏好。"""
async with _user_write_context(tmp_path / "rename.db") as (session, sync_factory):
user = User(name="old", is_active=True, is_superuser=False)
session.add_all(
[
user,
UserConfig(username="old", key="theme", value="dark"),
]
)
await session.commit()
configuration = _configuration_service(sync_factory)
service = UserService(
SqlAlchemyUserRepository(session),
SqlAlchemyAsyncUnitOfWork(session),
configuration,
)
renamed = await service.update(user.id, {"name": "new"})
assert renamed is not None
assert renamed.name == "new"
configs = (await session.execute(select(UserConfig))).scalars().all()
assert [(item.username, item.key, item.value) for item in configs] == [("new", "theme", "dark")]
assert configuration.get("old", "theme") is None
assert configuration.get("new", "theme") == "dark"
@pytest.mark.asyncio
async def test_user_delete_removes_configuration_and_passkeys(tmp_path) -> None:
"""删除用户必须在同一事务中清理字符串偏好和外键凭据。"""
async with _user_write_context(tmp_path / "delete.db") as (session, sync_factory):
user = User(name="member", is_active=True, is_superuser=False)
session.add(user)
await session.flush()
session.add_all(
[
UserConfig(username="member", key="theme", value="dark"),
PassKey(
user_id=user.id,
credential_id="credential",
public_key="public-key",
),
]
)
await session.commit()
configuration = _configuration_service(sync_factory)
service = UserService(
SqlAlchemyUserRepository(session),
SqlAlchemyAsyncUnitOfWork(session),
configuration,
)
await service.delete(user.id)
assert (await session.execute(select(User))).scalar_one_or_none() is None
assert (await session.execute(select(UserConfig))).scalar_one_or_none() is None
assert (await session.execute(select(PassKey))).scalar_one_or_none() is None
assert configuration.get("member", "theme") is None
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [{"is_active": False}, {"is_superuser": False}])
async def test_last_active_superuser_cannot_be_disabled_or_demoted(
tmp_path,
payload,
) -> None:
"""更新最后一个启用管理员时必须拒绝停用和降权。"""
async with _user_write_context(tmp_path / "last-admin-update.db") as (session, sync_factory):
admin = User(name="admin", is_active=True, is_superuser=True)
session.add(admin)
await session.commit()
service = UserService(
SqlAlchemyUserRepository(session),
SqlAlchemyAsyncUnitOfWork(session),
_configuration_service(sync_factory),
)
with pytest.raises(LastActiveSuperuserError):
await service.update(admin.id, payload)
persisted = (await session.execute(select(User))).scalar_one()
assert persisted.is_active is True
assert persisted.is_superuser is True
@pytest.mark.asyncio
async def test_last_active_superuser_cannot_be_deleted(tmp_path) -> None:
"""删除最后一个启用管理员必须完整回滚。"""
async with _user_write_context(tmp_path / "last-admin-delete.db") as (session, sync_factory):
admin = User(name="admin", is_active=True, is_superuser=True)
session.add(admin)
await session.commit()
service = UserService(
SqlAlchemyUserRepository(session),
SqlAlchemyAsyncUnitOfWork(session),
_configuration_service(sync_factory),
)
with pytest.raises(LastActiveSuperuserError):
await service.delete(admin.id)
assert (await session.execute(select(User))).scalar_one().name == "admin"
@pytest.mark.asyncio
async def test_superuser_can_be_deleted_when_another_active_admin_remains(tmp_path) -> None:
"""存在另一个启用管理员时允许删除目标管理员。"""
async with _user_write_context(tmp_path / "multiple-admins.db") as (session, sync_factory):
first = User(name="first", is_active=True, is_superuser=True)
second = User(name="second", is_active=True, is_superuser=True)
session.add_all([first, second])
await session.commit()
service = UserService(
SqlAlchemyUserRepository(session),
SqlAlchemyAsyncUnitOfWork(session),
_configuration_service(sync_factory),
)
await service.delete(first.id)
names = (await session.execute(select(User.name))).scalars().all()
assert names == ["second"]
@pytest.mark.asyncio
async def test_database_unique_constraint_is_mapped_to_application_error(tmp_path) -> None:
"""并发前置检查失效后,数据库唯一约束仍返回稳定应用错误。"""
async with _user_write_context(tmp_path / "duplicate.db") as (session, sync_factory):
session.add(User(name="duplicate", is_active=True, is_superuser=False))
await session.commit()
service = UserService(
SqlAlchemyUserRepository(session),
SqlAlchemyAsyncUnitOfWork(session),
_configuration_service(sync_factory),
)
with pytest.raises(UserNameConflictError):
await service.create({"name": "duplicate"})
users = (await session.execute(select(User))).scalars().all()
assert [item.name for item in users] == ["duplicate"]
async def _race_user_mutation_and_config_set(
*,
database_path,
mutation: str,
set_username: str,
) -> tuple[list[UserConfig], object]:
"""并发执行用户身份变更和配置写入并返回最终数据库、快照。"""
async with _user_write_context(database_path) as (session, sync_factory):
user = User(name="old", is_active=True, is_superuser=False)
session.add_all(
[
user,
UserConfig(username="old", key="theme", value="initial"),
]
)
await session.commit()
configuration = _configuration_service(sync_factory)
service = UserService(
SqlAlchemyUserRepository(session),
SqlAlchemyAsyncUnitOfWork(session),
configuration,
)
barrier = threading.Barrier(2)
def set_config() -> None:
"""与用户事务同时尝试提交配置。"""
barrier.wait()
configuration.set(set_username, "theme", "concurrent")
async def mutate_user() -> None:
"""与配置事务同时提交改名或删除。"""
await asyncio.to_thread(barrier.wait)
if mutation == "rename":
await service.update(user.id, {"name": "new"})
else:
await service.delete(user.id)
results = await asyncio.gather(
asyncio.to_thread(set_config),
mutate_user(),
return_exceptions=True,
)
for result in results:
if isinstance(result, BaseException):
assert isinstance(result, IntegrityError)
session.expire_all()
rows = list((await session.execute(select(UserConfig))).scalars().all())
snapshot = configuration.get(
"new" if mutation == "rename" else "old",
"theme",
)
return rows, snapshot
@pytest.mark.asyncio
@pytest.mark.parametrize("set_username", ["old", "new"])
async def test_user_rename_and_config_set_are_consistent(
tmp_path,
set_username,
) -> None:
"""改名与旧名/新名配置真并发后不得产生孤儿或覆盖已提交值。"""
rows, snapshot = await _race_user_mutation_and_config_set(
database_path=tmp_path / f"rename-set-{set_username}.db",
mutation="rename",
set_username=set_username,
)
assert {row.username for row in rows} == {"new"}
assert len(rows) == 1
assert snapshot == rows[0].value
@pytest.mark.asyncio
async def test_user_delete_and_config_set_cannot_recreate_orphan(tmp_path) -> None:
"""删除与配置真并发后数据库和快照都不得重建无主体配置。"""
rows, snapshot = await _race_user_mutation_and_config_set(
database_path=tmp_path / "delete-set.db",
mutation="delete",
set_username="old",
)
assert rows == []
assert snapshot is None
+105 -3
View File
@@ -4,7 +4,15 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
from app.application.security.user import UserService
from app.application.security.user import UserService, UserSnapshot, UserUpdateResult
def _configuration_publisher() -> MagicMock:
"""构造可检查的提交后用户配置发布端口。"""
publisher = MagicMock()
publisher.rename = AsyncMock()
publisher.delete = AsyncMock()
return publisher
@pytest.mark.asyncio
@@ -15,11 +23,14 @@ async def test_user_service_commits_staged_mutation() -> None:
unit_of_work = MagicMock()
unit_of_work.commit = AsyncMock()
unit_of_work.rollback = AsyncMock()
service = UserService(repository, unit_of_work)
configuration = _configuration_publisher()
service = UserService(repository, unit_of_work, configuration)
assert await service.create({"name": "demo"}) == {"id": 7}
unit_of_work.commit.assert_awaited_once_with()
unit_of_work.rollback.assert_not_awaited()
configuration.rename.assert_not_awaited()
configuration.delete.assert_not_awaited()
@pytest.mark.asyncio
@@ -30,10 +41,101 @@ async def test_user_service_rolls_back_failed_mutation() -> None:
unit_of_work = MagicMock()
unit_of_work.commit = AsyncMock()
unit_of_work.rollback = AsyncMock()
service = UserService(repository, unit_of_work)
configuration = _configuration_publisher()
service = UserService(repository, unit_of_work, configuration)
with pytest.raises(RuntimeError, match="write failed"):
await service.delete(7)
unit_of_work.rollback.assert_awaited_once_with()
unit_of_work.commit.assert_not_awaited()
configuration.delete.assert_not_awaited()
@pytest.mark.asyncio
async def test_user_service_rolls_back_failed_commit() -> None:
"""提交阶段失败时也必须显式回滚当前用户聚合事务。"""
repository = MagicMock()
repository.async_update = AsyncMock(
return_value=UserUpdateResult(
user=UserSnapshot.build(
user_id=7,
name="demo",
email=None,
is_active=True,
is_superuser=False,
avatar=None,
is_otp=False,
permissions=None,
settings=None,
),
previous_name="old",
)
)
unit_of_work = MagicMock()
unit_of_work.commit = AsyncMock(side_effect=RuntimeError("commit failed"))
unit_of_work.rollback = AsyncMock()
configuration = _configuration_publisher()
service = UserService(repository, unit_of_work, configuration)
with pytest.raises(RuntimeError, match="commit failed"):
await service.update(7, {"name": "demo"})
unit_of_work.commit.assert_awaited_once_with()
unit_of_work.rollback.assert_awaited_once_with()
configuration.rename.assert_not_awaited()
@pytest.mark.asyncio
async def test_user_service_publishes_rename_only_after_commit() -> None:
"""用户改名必须先提交聚合事务,再同步用户名配置快照。"""
calls: list[str] = []
repository = MagicMock()
repository.async_update = AsyncMock(
return_value=UserUpdateResult(
user=UserSnapshot.build(
user_id=7,
name="new",
email=None,
is_active=True,
is_superuser=False,
avatar=None,
is_otp=False,
permissions=None,
settings=None,
),
previous_name="old",
)
)
unit_of_work = MagicMock()
unit_of_work.commit = AsyncMock(side_effect=lambda: calls.append("commit"))
unit_of_work.rollback = AsyncMock()
configuration = _configuration_publisher()
configuration.rename = AsyncMock(side_effect=lambda *_args: calls.append("publish"))
service = UserService(repository, unit_of_work, configuration)
result = await service.update(7, {"name": "new"})
assert result is not None
assert result.name == "new"
assert calls == ["commit", "publish"]
configuration.rename.assert_awaited_once_with("old", "new")
@pytest.mark.asyncio
async def test_user_service_does_not_rollback_committed_publish_failure() -> None:
"""提交后快照发布失败不得伪装成可回滚的用户事务。"""
repository = MagicMock()
repository.async_delete = AsyncMock(return_value="member")
unit_of_work = MagicMock()
unit_of_work.commit = AsyncMock()
unit_of_work.rollback = AsyncMock()
configuration = _configuration_publisher()
configuration.delete = AsyncMock(side_effect=RuntimeError("publish failed"))
service = UserService(repository, unit_of_work, configuration)
with pytest.raises(RuntimeError, match="publish failed"):
await service.delete(7)
unit_of_work.commit.assert_awaited_once_with()
unit_of_work.rollback.assert_not_awaited()
+207 -18
View File
@@ -2,13 +2,18 @@
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
from types import SimpleNamespace
import pytest
from app.application.security.userconfig import UserConfigurationService
from app.db.adapters.configuration import TransactionalUserConfigurationRepository
from app.db.models.user import User
from app.db.models.userconfig import UserConfig
from app.db.oper.userconfig import UserConfigOper
from app.db.session import SessionFactory
from app.db.uow import SqlAlchemyUnitOfWork
from app.foundation.singleton import Singleton
@@ -24,10 +29,26 @@ def _fresh_oper() -> UserConfigOper:
"""重置单例并显式加载用户配置快照。"""
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
oper = UserConfigOper()
oper.load_snapshot()
with SessionFactory() as session:
oper.load_snapshot(session)
return oper
def _fresh_repository() -> tuple[
TransactionalUserConfigurationRepository,
UserConfigOper,
]:
"""构造使用新快照单例的用户配置短事务仓储。"""
oper = _fresh_oper()
return TransactionalUserConfigurationRepository(SessionFactory, oper), oper
def _add_users(db, *usernames: str) -> None:
"""为配置用例创建受外键保护的真实用户主体。"""
db.watermark(User)
db.add(*[User(name=username, is_active=True, is_superuser=False) for username in usernames])
def test_constructor_does_not_query_database(monkeypatch):
"""构造用户配置对象时不打开数据库会话。"""
Singleton._instances.pop((UserConfigOper, (), frozenset()), None)
@@ -77,44 +98,50 @@ def test_load_snapshot_publishes_complete_dictionary(monkeypatch):
@pytest.mark.asyncio
async def test_async_write_uses_same_repository_rule(db) -> None:
"""异步入口提交后同步读取立即看到相同结果。"""
oper = _fresh_oper()
_add_users(db, "async-user")
repository, _oper = _fresh_repository()
service = UserConfigurationService(
oper,
repository,
async_executor=_ThreadDatabaseExecutor(),
)
await service.async_set("async-user", "theme", "dark")
assert service.get("async-user", "theme") == "dark"
assert UserConfig.get_by_key(
db.session,
username="async-user",
key="theme",
).value == "dark"
assert (
UserConfig.get_by_key(
db.session,
username="async-user",
key="theme",
).value
== "dark"
)
def test_existing_falsey_value_is_removed_from_db_but_kept_until_reload(db) -> None:
"""已有用户配置写入假值时删除记录,当前快照仍保留该假值直到重载"""
def test_existing_falsey_value_removes_database_and_snapshot_entry(db) -> None:
"""已有用户配置写入假值时,数据库记录与快照同步移除"""
db.watermark(UserConfig)
_add_users(db, "falsey-user")
oper = _fresh_oper()
oper.set("falsey-user", "enabled", True)
oper.set("falsey-user", "enabled", False)
assert UserConfig.get_by_key(
db.session,
username="falsey-user",
key="enabled",
) is None
assert oper.get("falsey-user", "enabled") is False
oper.load_snapshot()
assert (
UserConfig.get_by_key(
db.session,
username="falsey-user",
key="enabled",
)
is None
)
assert oper.get("falsey-user", "enabled") is None
def test_falsey_value_without_existing_row_is_persisted(db) -> None:
"""不存在的用户配置写入假值时保留记录,兼容历史写入规则。"""
db.watermark(UserConfig)
_add_users(db, "new-falsey-user")
oper = _fresh_oper()
oper.set("new-falsey-user", "enabled", False)
@@ -127,3 +154,165 @@ def test_falsey_value_without_existing_row_is_persisted(db) -> None:
assert persisted is not None
assert persisted.value is False
assert oper.get("new-falsey-user", "enabled") is False
def test_repository_deeply_isolates_inputs_and_outputs(db) -> None:
"""嵌套可变配置在写入和读取两端都不得泄漏快照内部引用。"""
db.watermark(UserConfig)
_add_users(db, "isolated-user")
repository, _oper = _fresh_repository()
payload = {"nested": {"items": ["original"]}}
repository.set("isolated-user", "layout", payload)
payload["nested"]["items"].append("input-mutated")
first = repository.get("isolated-user", "layout")
assert first == {"nested": {"items": ["original"]}}
first["nested"]["items"].append("output-mutated")
assert repository.get("isolated-user", "layout") == {"nested": {"items": ["original"]}}
db.session.expire_all()
assert UserConfig.get_by_key(
db.session,
username="isolated-user",
key="layout",
).value == {"nested": {"items": ["original"]}}
def test_commit_failure_never_publishes_snapshot(db, monkeypatch) -> None:
"""数据库提交失败必须回滚暂存值,并保持既有快照不变。"""
db.watermark(UserConfig)
_add_users(db, "commit-user")
repository, oper = _fresh_repository()
repository.set("commit-user", "theme", "old")
published = threading.Event()
original_publish = oper.publish
def track_publish(*args, **kwargs) -> None:
"""记录任何不应发生的提交后发布。"""
published.set()
original_publish(*args, **kwargs)
def fail_commit(_unit_of_work) -> None:
"""模拟数据库在提交边界失败。"""
raise RuntimeError("commit failed")
monkeypatch.setattr(oper, "publish", track_publish)
monkeypatch.setattr(SqlAlchemyUnitOfWork, "commit", fail_commit)
with pytest.raises(RuntimeError, match="commit failed"):
repository.set("commit-user", "theme", "new")
assert published.is_set() is False
assert repository.get("commit-user", "theme") == "old"
db.session.expire_all()
assert (
UserConfig.get_by_key(
db.session,
username="commit-user",
key="theme",
).value
== "old"
)
def test_publish_failure_reloads_committed_database_value(db, monkeypatch) -> None:
"""提交成功但增量发布失败时,从数据库重载快照后再传播异常。"""
db.watermark(UserConfig)
_add_users(db, "reload-user")
repository, oper = _fresh_repository()
repository.set("reload-user", "theme", "old")
def fail_publish(*_args, **_kwargs) -> None:
"""模拟提交后的内存快照发布失败。"""
raise RuntimeError("publish failed")
monkeypatch.setattr(oper, "publish", fail_publish)
with pytest.raises(RuntimeError, match="publish failed"):
repository.set("reload-user", "theme", "committed")
assert repository.get("reload-user", "theme") == "committed"
db.session.expire_all()
assert (
UserConfig.get_by_key(
db.session,
username="reload-user",
key="theme",
).value
== "committed"
)
def test_rename_publish_failure_reloads_committed_database_state(
db,
monkeypatch,
) -> None:
"""改名已提交但快照迁移失败时,重载后只能看到新用户名配置。"""
db.watermark(UserConfig)
_add_users(db, "old-name")
repository, oper = _fresh_repository()
repository.set("old-name", "theme", "dark")
user = User.get_by_name(db.session, "old-name")
user.name = "new-name"
db.session.commit()
def fail_publish(*_args, **_kwargs) -> None:
"""模拟改名提交后的增量快照发布失败。"""
raise RuntimeError("rename publish failed")
monkeypatch.setattr(oper, "publish_rename", fail_publish)
with pytest.raises(RuntimeError, match="rename publish failed"):
repository.publish_rename("old-name", "new-name")
assert repository.get("old-name", "theme") is None
assert repository.get("new-name", "theme") == "dark"
def test_delete_publish_failure_reloads_committed_database_state(
db,
monkeypatch,
) -> None:
"""用户删除已提交但快照删除失败时,重载后不得残留旧用户名配置。"""
db.watermark(UserConfig)
_add_users(db, "deleted-user")
repository, oper = _fresh_repository()
repository.set("deleted-user", "theme", "dark")
db.session.delete(User.get_by_name(db.session, "deleted-user"))
db.session.commit()
def fail_publish(*_args, **_kwargs) -> None:
"""模拟删除提交后的增量快照发布失败。"""
raise RuntimeError("delete publish failed")
monkeypatch.setattr(oper, "publish_delete", fail_publish)
with pytest.raises(RuntimeError, match="delete publish failed"):
repository.publish_delete("deleted-user")
assert repository.get("deleted-user", "theme") is None
def test_concurrent_writes_keep_database_and_snapshot_consistent(db) -> None:
"""同一配置的并发短事务按提交顺序发布,最终快照与数据库一致。"""
db.watermark(UserConfig)
_add_users(db, "concurrent-user")
repository, _oper = _fresh_repository()
values = [{"revision": revision} for revision in range(12)]
with ThreadPoolExecutor(max_workers=6) as executor:
list(
executor.map(
lambda value: repository.set("concurrent-user", "layout", value),
values,
)
)
db.session.expire_all()
persisted = UserConfig.get_by_key(
db.session,
username="concurrent-user",
key="layout",
)
assert persisted is not None
assert repository.get("concurrent-user", "layout") == persisted.value