mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 07:27:15 +08:00
refactor: close transactional boundary debt batch
This commit is contained in:
+59
-49
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": [
|
||||
"后台任务登记器",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.*",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",)),
|
||||
|
||||
@@ -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
@@ -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 异步客户端应使用阻塞连接池,避免高并发缓存读取立刻抛出连接耗尽错误。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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:")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 == "必须保留至少一个启用的超级管理员"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user