From 9e503becd3f437da76b76510d945194e8ca18c86 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Sun, 31 May 2026 16:44:13 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugin):=20=E5=AE=9E=E7=8E=B0=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E7=9A=84=E6=8F=92=E4=BB=B6=E6=B3=A8=E5=86=8C=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E7=B3=BB=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增了插件相关的完整领域模型、应用服务、基础设施实现,包括: 1. 插件状态、注册模式、来源等基础枚举和数据结构 2. 插件清单解析、发现、加载工具类 3. 插件注册表领域服务和内存存储实现 4. 插件相关的命令、查询、事件定义 5. 插件REST API接口和DTO映射 6. 集成了原有通道适配器到插件系统 7. 新增内置插件注册和自动发现能力 --- .../application/command/plugin/__init__.py | 6 + .../command/plugin/activate_command.py | 9 + .../command/plugin/deactivate_command.py | 8 + .../command/plugin/register_command.py | 11 + .../command/plugin/reload_command.py | 9 + .../channel/application/dto/plugin_dto.py | 39 + .../event/handler/plugin_event_handler.py | 30 + .../middlewares/validation_middleware.py | 3 + .../application/query/plugin/__init__.py | 6 + .../query/plugin/get_plugin_manifest_query.py | 8 + .../query/plugin/list_plugins_query.py | 9 + .../application/service/binding_service.py | 31 +- .../application/service/config_service.py | 28 +- .../application/service/delivery_service.py | 12 +- .../application/service/inbound_service.py | 2 + .../service/plugin_registry_app_service.py | 207 +++++ .../package/yuxi/channel/channel_config.yaml | 1 + .../package/yuxi/channel/channels/__init__.py | 8 +- .../yuxi/channel/channels/_registry.py | 55 +- .../channel/channels/dingtalk/__init__.py | 18 +- .../yuxi/channel/channels/dingtalk/adapter.py | 14 +- .../yuxi/channel/channels/dingtalk/config.py | 6 +- .../yuxi/channel/channels/dingtalk/routes.py | 49 +- .../channel/channels/dingtalk/translator.py | 6 +- .../channel/channels/dingtalk/verifier.py | 53 ++ .../yuxi/channel/channels/feishu/__init__.py | 16 +- .../yuxi/channel/channels/feishu/adapter.py | 18 +- .../yuxi/channel/channels/feishu/config.py | 7 +- .../yuxi/channel/channels/feishu/routes.py | 63 +- .../channel/channels/feishu/translator.py | 6 +- .../yuxi/channel/channels/feishu/verifier.py | 52 ++ .../yuxi/channel/channels/hooks/__init__.py | 21 +- .../yuxi/channel/channels/hooks/adapter.py | 40 +- .../yuxi/channel/channels/hooks/config.py | 4 + .../yuxi/channel/channels/hooks/outbound.py | 30 + .../yuxi/channel/channels/hooks/routes.py | 109 +-- .../yuxi/channel/channels/hooks/translator.py | 7 +- .../yuxi/channel/channels/hooks/verifier.py | 51 ++ .../yuxi/channel/channels/web/__init__.py | 16 +- .../yuxi/channel/channels/web/adapter.py | 9 +- .../yuxi/channel/channels/web/config.py | 6 +- .../yuxi/channel/channels/web/routes.py | 25 +- .../yuxi/channel/channels/web/translator.py | 6 +- .../yuxi/channel/channels/web/verifier.py | 34 + backend/package/yuxi/channel/container.py | 207 ++++- .../channel/domain/event/plugin/__init__.py | 17 + .../event/plugin/plugin_activated_event.py | 9 + .../event/plugin/plugin_deactivated_event.py | 9 + .../event/plugin/plugin_registered_event.py | 9 + .../event/plugin/plugin_reload_event.py | 11 + .../yuxi/channel/domain/exception/__init__.py | 54 +- .../exception/agent_config_not_found.py | 7 + .../domain/exception/duplicate_binding.py | 9 + .../domain/exception/plugin_exception.py | 92 +++ .../domain/exception/session_exception.py | 9 + .../domain/model/binding/channel_binding.py | 5 +- .../channel/domain/model/outbox/__init__.py | 3 +- .../domain/model/outbox/outbox_entry.py | 3 +- .../domain/model/outbox/outbox_status.py | 12 + .../domain/model/plugin_registry/__init__.py | 23 + .../model/plugin_registry/plugin_manifest.py | 24 + .../plugin_persistent_record.py | 54 ++ .../model/plugin_registry/plugin_record.py | 97 +++ .../plugin_registration_mode.py | 10 + .../model/plugin_registry/plugin_registry.py | 173 ++++ .../plugin_registry_factory.py | 67 ++ .../model/plugin_registry/plugin_source.py | 18 + .../model/plugin_registry/plugin_status.py | 12 + .../yuxi/channel/domain/port/__init__.py | 42 +- .../domain/port/agent_config_lookup_port.py | 8 + .../yuxi/channel/domain/port/cache_port.py | 2 + .../port/channel_request_verifier_port.py | 22 + .../yuxi/channel/domain/port/metrics_port.py | 2 + .../channel/domain/port/plugin_loader_port.py | 10 + .../port/plugin_registration_api_port.py | 11 + .../channel/domain/repository/__init__.py | 16 +- .../domain/repository/binding_repository.py | 6 +- .../repository/message_log_repository.py | 5 +- .../domain/repository/message_repository.py | 8 +- .../domain/repository/outbox_repository.py | 2 +- .../repository/plugin_audit_log_repository.py | 26 + .../plugin_config_history_repository.py | 20 + .../domain/repository/plugin_repository.py | 38 + .../service/plugin_registry_domain_service.py | 24 + .../infrastructure/config/channel_config.py | 10 + .../metrics/prometheus_metrics.py | 12 + .../converter/binding_converter.py | 6 +- .../persistence/converter/plugin_converter.py | 107 +++ .../converter/session_converter.py | 17 + .../repository/pg_agent_config_lookup.py | 15 + .../repository/pg_binding_repository.py | 99 ++- .../repository/pg_message_log_repository.py | 33 +- .../repository/pg_message_repository.py | 42 +- .../repository/pg_outbox_repository.py | 117 +-- .../pg_plugin_audit_log_repository.py | 82 ++ .../pg_plugin_config_history_repository.py | 71 ++ .../repository/pg_plugin_repository.py | 226 ++++++ .../repository/pg_session_repository.py | 47 +- .../channel/infrastructure/plugin/__init__.py | 11 + .../infrastructure/plugin/api_guard.py | 35 + .../infrastructure/plugin/bundled/__init__.py | 5 + .../plugin/bundled/register_builtin.py | 43 + .../infrastructure/plugin/discovery.py | 84 ++ .../channel/infrastructure/plugin/loader.py | 48 ++ .../infrastructure/plugin/manifest_parser.py | 70 ++ .../infrastructure/plugin/registry_impl.py | 212 +++++ .../channel/infrastructure/plugin/snapshot.py | 35 + .../interfaces/dto/request/plugin_request.py | 11 + .../interfaces/dto/response/error_response.py | 9 + .../dto/response/plugin_response.py | 56 ++ .../channel/interfaces/rest/auth/depends.py | 20 + .../channel/interfaces/rest/dto}/__init__.py | 0 .../interfaces/rest/dto/accepted_response.py | 7 + .../interfaces/rest/dto/error_response.py | 8 + .../interfaces/rest/dto/idempotency.py | 15 + .../interfaces/rest/router/__init__.py | 2 + .../interfaces/rest/router/bindings.py | 60 +- .../interfaces/rest/router/channel_status.py | 82 +- .../rest/router/contributor.py} | 0 .../channel/interfaces/rest/router/health.py | 59 +- .../channel/interfaces/rest/router/metrics.py | 8 +- .../interfaces/rest/router/plugin_registry.py | 218 ++++++ .../interfaces/rest/router/registry.py | 2 +- .../channel/interfaces/rest/router/sse.py | 90 ++- .../yuxi/channel/interfaces/sse/endpoint.py | 8 +- .../channel/interfaces/websocket/manager.py | 9 +- backend/package/yuxi/channel/startup.py | 25 + .../yuxi/channel/worker/outbox_retry.py | 22 +- .../package/yuxi/storage/postgres/manager.py | 86 ++ .../yuxi/storage/postgres/models_channel.py | 149 +++- backend/server/routers/channel_router.py | 8 +- backend/server/utils/lifespan.py | 37 +- backend/test/integration/channel/__init__.py | 0 backend/test/integration/channel/conftest.py | 29 + .../channel/test_bindings_router.py | 176 +++++ .../channel/test_channel_inbound_router.py | 116 +++ .../channel/test_channel_status_router.py | 55 ++ .../channel/test_config_reload_router.py | 39 + .../integration/channel/test_health_router.py | 57 ++ .../channel/test_metrics_router.py | 26 + .../channel/test_plugin_registry_router.py | 162 ++++ .../integration/channel/test_sse_router.py | 54 ++ backend/test/unit/channel/__init__.py | 1 - .../test/unit/channel/application/__init__.py | 1 - .../channel/application/command/__init__.py | 0 .../application/command/plugin/__init__.py | 0 .../command/plugin/test_commands.py | 53 ++ .../unit/channel/application/dto/__init__.py | 1 - .../application/dto/test_plugin_dto.py | 75 ++ .../channel/application/event/__init__.py | 0 .../application/event/handler/__init__.py | 0 .../handler/test_plugin_event_handler.py | 47 ++ .../channel/application/pipeline/__init__.py | 1 - .../pipeline/middlewares/__init__.py | 1 - .../test_access_policy_middleware.py | 8 +- .../middlewares/test_enqueue_mq_middleware.py | 4 +- .../middlewares/test_rate_limit_middleware.py | 4 +- .../channel/application/query/__init__.py | 0 .../application/query/plugin/__init__.py | 0 .../application/query/plugin/test_queries.py | 21 + .../channel/application/service/__init__.py | 1 - .../service/test_binding_service.py | 19 +- .../service/test_config_service.py | 4 +- .../service/test_delivery_service.py | 18 +- .../service/test_dispatch_service.py | 4 +- .../test_plugin_registry_app_service.py | 274 +++++++ .../service/test_session_resolver.py | 27 +- .../test/unit/channel/channels/__init__.py | 1 - .../channel/channels/dingtalk/__init__.py | 1 - .../channels/dingtalk/test_dingtalk.py | 139 ++++ .../unit/channel/channels/feishu/__init__.py | 1 - .../channel/channels/feishu/test_feishu.py | 240 ++++++ .../unit/channel/channels/hooks/__init__.py | 1 - .../unit/channel/channels/hooks/test_hooks.py | 197 +++++ .../unit/channel/channels/test_registry.py | 20 + .../unit/channel/channels/web/__init__.py | 1 - .../unit/channel/channels/web/test_web.py | 173 ++++ .../test/unit/channel/container/__init__.py | 1 - .../test_container_factory_build_workers.py | 8 +- .../container/test_container_startup.py | 8 +- backend/test/unit/channel/domain/__init__.py | 1 - .../unit/channel/domain/event/__init__.py | 1 - .../domain/event/test_plugin_events.py | 38 + .../unit/channel/domain/exception/__init__.py | 1 - .../domain/exception/test_plugin_exception.py | 100 +++ .../channel/domain/middleware/__init__.py | 1 - .../unit/channel/domain/model/__init__.py | 1 - .../channel/domain/model/binding/__init__.py | 1 - .../channel/domain/model/message/__init__.py | 1 - .../channel/domain/model/outbox/__init__.py | 1 - .../domain/model/plugin_registry/__init__.py | 0 .../plugin_registry/test_plugin_models.py | 356 +++++++++ .../plugin_registry/test_plugin_registry.py | 240 ++++++ .../test_plugin_registry_advanced.py | 309 ++++++++ .../channel/domain/model/session/__init__.py | 1 - .../channel/domain/model/shared/__init__.py | 1 - .../test/unit/channel/domain/port/__init__.py | 1 - .../port/test_channel_route_contributor.py | 2 +- .../channel/domain/port/test_plugin_ports.py | 41 + .../channel/domain/repository/__init__.py | 1 - .../repository/test_binding_repository.py | 10 +- .../repository/test_message_log_repository.py | 7 +- .../repository/test_outbox_repository.py | 4 +- .../unit/channel/domain/service/__init__.py | 1 - .../test_plugin_registry_domain_service.py | 83 ++ .../unit/channel/infrastructure/__init__.py | 1 - .../channel/infrastructure/agent/__init__.py | 1 - .../agent/test_agent_adapter.py | 44 +- .../channel/infrastructure/config/__init__.py | 1 - .../config/test_redis_config_reload.py | 33 +- .../infrastructure/content_filter/__init__.py | 1 - .../test_redis_content_filter.py | 1 + .../infrastructure/messaging/__init__.py | 1 - .../messaging/test_redis_pubsub_subscriber.py | 56 +- .../messaging/test_redis_stream_queue.py | 31 +- .../infrastructure/metrics/__init__.py | 1 - .../infrastructure/persistence/__init__.py | 1 - .../persistence/converter/__init__.py | 1 - .../persistence/repository/__init__.py | 1 - .../repository/test_pg_binding_repository.py | 33 +- .../test_pg_message_log_repository.py | 23 +- .../repository/test_pg_message_repository.py | 106 ++- .../repository/test_pg_outbox_repository.py | 93 ++- .../repository/test_pg_session_repository.py | 8 +- .../channel/infrastructure/plugin/__init__.py | 0 .../infrastructure/plugin/test_api_guard.py | 36 + .../plugin/test_api_guard_and_factory.py | 72 ++ .../infrastructure/plugin/test_discovery.py | 87 +++ .../infrastructure/plugin/test_loader.py | 85 ++ .../plugin/test_manifest_parser.py | 76 ++ .../plugin/test_registry_impl.py | 364 +++++++++ .../infrastructure/plugin/test_snapshot.py | 114 +++ .../infrastructure/security/__init__.py | 1 - .../test/unit/channel/interfaces/__init__.py | 1 - .../unit/channel/interfaces/rest/__init__.py | 1 - .../channel/interfaces/rest/auth/__init__.py | 1 - .../interfaces/rest/auth/test_depends.py | 6 +- .../interfaces/rest/middleware/__init__.py | 1 - .../rest/middleware/test_exception_handler.py | 1 + .../rest/middleware/test_trace_id.py | 42 +- .../interfaces/rest/router/__init__.py | 1 - .../interfaces/rest/router/test_bindings.py | 291 +++++-- .../rest/router/test_channel_status.py | 216 +++-- .../rest/router/test_config_reload.py | 107 +++ .../interfaces/rest/router/test_health.py | 245 ++++++ .../interfaces/rest/router/test_metrics.py | 104 +++ .../rest/router/test_plugin_registry.py | 345 ++++++++ .../interfaces/rest/router/test_registry.py | 84 +- .../interfaces/rest/router/test_sse.py | 81 ++ .../unit/channel/interfaces/sse/__init__.py | 1 - .../channel/interfaces/sse/test_endpoint.py | 2 + .../channel/interfaces/websocket/__init__.py | 1 - .../interfaces/websocket/test_manager.py | 2 + .../channel/startup/test_agent_adapter.py | 67 -- .../startup/test_aho_corasick_matcher.py | 45 -- .../unit/channel/startup/test_auth_service.py | 98 --- .../channel/startup/test_binding_service.py | 85 -- .../channel/startup/test_channel_binding.py | 70 -- .../channel/startup/test_channel_config.py | 195 ----- .../channel/startup/test_channel_container.py | 131 ---- .../channel/startup/test_channel_registry.py | 55 -- .../channel/startup/test_channel_session.py | 89 --- .../startup/test_composite_content_filter.py | 78 -- .../channel/startup/test_config_service.py | 81 -- .../channel/startup/test_container_factory.py | 140 ---- .../channel/startup/test_delivery_service.py | 146 ---- .../channel/startup/test_dingtalk_adapter.py | 74 -- .../channel/startup/test_dispatch_service.py | 130 --- .../channel/startup/test_domain_events.py | 105 --- .../channel/startup/test_domain_exceptions.py | 68 -- .../channel/startup/test_feishu_adapter.py | 74 -- .../startup/test_hmac_signature_verifier.py | 40 - .../channel/startup/test_hooks_adapter.py | 74 -- .../channel/startup/test_inbound_service.py | 113 --- .../startup/test_llm_content_filter.py | 44 -- .../channel/startup/test_message_context.py | 67 -- .../unit/channel/startup/test_middlewares.py | 367 --------- .../unit/channel/startup/test_outbox_retry.py | 116 --- .../unit/channel/startup/test_pipeline.py | 87 --- .../channel/startup/test_pipeline_builder.py | 116 --- .../startup/test_prometheus_metrics.py | 737 ------------------ .../startup/test_redis_bot_loop_guard.py | 46 -- .../unit/channel/startup/test_redis_cache.py | 72 -- .../startup/test_redis_circuit_breaker.py | 64 -- .../startup/test_redis_config_reload.py | 52 -- .../startup/test_redis_content_filter.py | 52 -- .../unit/channel/startup/test_redis_pubsub.py | 89 --- .../startup/test_redis_rate_limiter.py | 63 -- .../startup/test_redis_stream_queue.py | 89 --- .../channel/startup/test_session_factory.py | 100 --- .../channel/startup/test_session_resolver.py | 187 ----- .../channel/startup/test_setup_channel.py | 47 -- .../unit/channel/startup/test_sse_endpoint.py | 71 -- .../channel/startup/test_startup_module.py | 76 -- .../channel/startup/test_startup_tracer.py | 62 -- .../channel/startup/test_unified_message.py | 107 --- .../unit/channel/startup/test_web_adapter.py | 74 -- .../unit/channel/startup/test_worker_pool.py | 78 -- .../unit/channel/startup/test_ws_manager.py | 87 --- backend/test/unit/channel/worker/__init__.py | 1 - .../unit/channel/worker/test_outbox_retry.py | 83 +- docs/develop-guides/roadmap.md | 32 +- 302 files changed, 9982 insertions(+), 5861 deletions(-) create mode 100644 backend/package/yuxi/channel/application/command/plugin/__init__.py create mode 100644 backend/package/yuxi/channel/application/command/plugin/activate_command.py create mode 100644 backend/package/yuxi/channel/application/command/plugin/deactivate_command.py create mode 100644 backend/package/yuxi/channel/application/command/plugin/register_command.py create mode 100644 backend/package/yuxi/channel/application/command/plugin/reload_command.py create mode 100644 backend/package/yuxi/channel/application/dto/plugin_dto.py create mode 100644 backend/package/yuxi/channel/application/event/handler/plugin_event_handler.py create mode 100644 backend/package/yuxi/channel/application/query/plugin/__init__.py create mode 100644 backend/package/yuxi/channel/application/query/plugin/get_plugin_manifest_query.py create mode 100644 backend/package/yuxi/channel/application/query/plugin/list_plugins_query.py create mode 100644 backend/package/yuxi/channel/application/service/plugin_registry_app_service.py create mode 100644 backend/package/yuxi/channel/channels/dingtalk/verifier.py create mode 100644 backend/package/yuxi/channel/channels/feishu/verifier.py create mode 100644 backend/package/yuxi/channel/channels/hooks/outbound.py create mode 100644 backend/package/yuxi/channel/channels/hooks/verifier.py create mode 100644 backend/package/yuxi/channel/channels/web/verifier.py create mode 100644 backend/package/yuxi/channel/domain/event/plugin/__init__.py create mode 100644 backend/package/yuxi/channel/domain/event/plugin/plugin_activated_event.py create mode 100644 backend/package/yuxi/channel/domain/event/plugin/plugin_deactivated_event.py create mode 100644 backend/package/yuxi/channel/domain/event/plugin/plugin_registered_event.py create mode 100644 backend/package/yuxi/channel/domain/event/plugin/plugin_reload_event.py create mode 100644 backend/package/yuxi/channel/domain/exception/agent_config_not_found.py create mode 100644 backend/package/yuxi/channel/domain/exception/duplicate_binding.py create mode 100644 backend/package/yuxi/channel/domain/exception/plugin_exception.py create mode 100644 backend/package/yuxi/channel/domain/exception/session_exception.py create mode 100644 backend/package/yuxi/channel/domain/model/outbox/outbox_status.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/__init__.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/plugin_manifest.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/plugin_persistent_record.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/plugin_record.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registration_mode.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registry.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registry_factory.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/plugin_source.py create mode 100644 backend/package/yuxi/channel/domain/model/plugin_registry/plugin_status.py create mode 100644 backend/package/yuxi/channel/domain/port/agent_config_lookup_port.py create mode 100644 backend/package/yuxi/channel/domain/port/channel_request_verifier_port.py create mode 100644 backend/package/yuxi/channel/domain/port/plugin_loader_port.py create mode 100644 backend/package/yuxi/channel/domain/port/plugin_registration_api_port.py create mode 100644 backend/package/yuxi/channel/domain/repository/plugin_audit_log_repository.py create mode 100644 backend/package/yuxi/channel/domain/repository/plugin_config_history_repository.py create mode 100644 backend/package/yuxi/channel/domain/repository/plugin_repository.py create mode 100644 backend/package/yuxi/channel/domain/service/plugin_registry_domain_service.py create mode 100644 backend/package/yuxi/channel/infrastructure/persistence/converter/plugin_converter.py create mode 100644 backend/package/yuxi/channel/infrastructure/persistence/converter/session_converter.py create mode 100644 backend/package/yuxi/channel/infrastructure/persistence/repository/pg_agent_config_lookup.py create mode 100644 backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_audit_log_repository.py create mode 100644 backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_config_history_repository.py create mode 100644 backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_repository.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/__init__.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/api_guard.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/bundled/__init__.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/bundled/register_builtin.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/discovery.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/loader.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/manifest_parser.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/registry_impl.py create mode 100644 backend/package/yuxi/channel/infrastructure/plugin/snapshot.py create mode 100644 backend/package/yuxi/channel/interfaces/dto/request/plugin_request.py create mode 100644 backend/package/yuxi/channel/interfaces/dto/response/error_response.py create mode 100644 backend/package/yuxi/channel/interfaces/dto/response/plugin_response.py rename backend/{test/unit/channel/startup => package/yuxi/channel/interfaces/rest/dto}/__init__.py (100%) create mode 100644 backend/package/yuxi/channel/interfaces/rest/dto/accepted_response.py create mode 100644 backend/package/yuxi/channel/interfaces/rest/dto/error_response.py create mode 100644 backend/package/yuxi/channel/interfaces/rest/dto/idempotency.py rename backend/package/yuxi/channel/{domain/port/channel_route_contributor.py => interfaces/rest/router/contributor.py} (100%) create mode 100644 backend/package/yuxi/channel/interfaces/rest/router/plugin_registry.py create mode 100644 backend/test/integration/channel/__init__.py create mode 100644 backend/test/integration/channel/conftest.py create mode 100644 backend/test/integration/channel/test_bindings_router.py create mode 100644 backend/test/integration/channel/test_channel_inbound_router.py create mode 100644 backend/test/integration/channel/test_channel_status_router.py create mode 100644 backend/test/integration/channel/test_config_reload_router.py create mode 100644 backend/test/integration/channel/test_health_router.py create mode 100644 backend/test/integration/channel/test_metrics_router.py create mode 100644 backend/test/integration/channel/test_plugin_registry_router.py create mode 100644 backend/test/integration/channel/test_sse_router.py create mode 100644 backend/test/unit/channel/application/command/__init__.py create mode 100644 backend/test/unit/channel/application/command/plugin/__init__.py create mode 100644 backend/test/unit/channel/application/command/plugin/test_commands.py create mode 100644 backend/test/unit/channel/application/dto/test_plugin_dto.py create mode 100644 backend/test/unit/channel/application/event/__init__.py create mode 100644 backend/test/unit/channel/application/event/handler/__init__.py create mode 100644 backend/test/unit/channel/application/event/handler/test_plugin_event_handler.py create mode 100644 backend/test/unit/channel/application/query/__init__.py create mode 100644 backend/test/unit/channel/application/query/plugin/__init__.py create mode 100644 backend/test/unit/channel/application/query/plugin/test_queries.py create mode 100644 backend/test/unit/channel/application/service/test_plugin_registry_app_service.py create mode 100644 backend/test/unit/channel/channels/dingtalk/test_dingtalk.py create mode 100644 backend/test/unit/channel/channels/feishu/test_feishu.py create mode 100644 backend/test/unit/channel/channels/hooks/test_hooks.py create mode 100644 backend/test/unit/channel/channels/test_registry.py create mode 100644 backend/test/unit/channel/channels/web/test_web.py create mode 100644 backend/test/unit/channel/domain/event/test_plugin_events.py create mode 100644 backend/test/unit/channel/domain/exception/test_plugin_exception.py create mode 100644 backend/test/unit/channel/domain/model/plugin_registry/__init__.py create mode 100644 backend/test/unit/channel/domain/model/plugin_registry/test_plugin_models.py create mode 100644 backend/test/unit/channel/domain/model/plugin_registry/test_plugin_registry.py create mode 100644 backend/test/unit/channel/domain/model/plugin_registry/test_plugin_registry_advanced.py create mode 100644 backend/test/unit/channel/domain/port/test_plugin_ports.py create mode 100644 backend/test/unit/channel/domain/service/test_plugin_registry_domain_service.py create mode 100644 backend/test/unit/channel/infrastructure/plugin/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/plugin/test_api_guard.py create mode 100644 backend/test/unit/channel/infrastructure/plugin/test_api_guard_and_factory.py create mode 100644 backend/test/unit/channel/infrastructure/plugin/test_discovery.py create mode 100644 backend/test/unit/channel/infrastructure/plugin/test_loader.py create mode 100644 backend/test/unit/channel/infrastructure/plugin/test_manifest_parser.py create mode 100644 backend/test/unit/channel/infrastructure/plugin/test_registry_impl.py create mode 100644 backend/test/unit/channel/infrastructure/plugin/test_snapshot.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/test_plugin_registry.py delete mode 100644 backend/test/unit/channel/startup/test_agent_adapter.py delete mode 100644 backend/test/unit/channel/startup/test_aho_corasick_matcher.py delete mode 100644 backend/test/unit/channel/startup/test_auth_service.py delete mode 100644 backend/test/unit/channel/startup/test_binding_service.py delete mode 100644 backend/test/unit/channel/startup/test_channel_binding.py delete mode 100644 backend/test/unit/channel/startup/test_channel_config.py delete mode 100644 backend/test/unit/channel/startup/test_channel_container.py delete mode 100644 backend/test/unit/channel/startup/test_channel_registry.py delete mode 100644 backend/test/unit/channel/startup/test_channel_session.py delete mode 100644 backend/test/unit/channel/startup/test_composite_content_filter.py delete mode 100644 backend/test/unit/channel/startup/test_config_service.py delete mode 100644 backend/test/unit/channel/startup/test_container_factory.py delete mode 100644 backend/test/unit/channel/startup/test_delivery_service.py delete mode 100644 backend/test/unit/channel/startup/test_dingtalk_adapter.py delete mode 100644 backend/test/unit/channel/startup/test_dispatch_service.py delete mode 100644 backend/test/unit/channel/startup/test_domain_events.py delete mode 100644 backend/test/unit/channel/startup/test_domain_exceptions.py delete mode 100644 backend/test/unit/channel/startup/test_feishu_adapter.py delete mode 100644 backend/test/unit/channel/startup/test_hmac_signature_verifier.py delete mode 100644 backend/test/unit/channel/startup/test_hooks_adapter.py delete mode 100644 backend/test/unit/channel/startup/test_inbound_service.py delete mode 100644 backend/test/unit/channel/startup/test_llm_content_filter.py delete mode 100644 backend/test/unit/channel/startup/test_message_context.py delete mode 100644 backend/test/unit/channel/startup/test_middlewares.py delete mode 100644 backend/test/unit/channel/startup/test_outbox_retry.py delete mode 100644 backend/test/unit/channel/startup/test_pipeline.py delete mode 100644 backend/test/unit/channel/startup/test_pipeline_builder.py delete mode 100644 backend/test/unit/channel/startup/test_prometheus_metrics.py delete mode 100644 backend/test/unit/channel/startup/test_redis_bot_loop_guard.py delete mode 100644 backend/test/unit/channel/startup/test_redis_cache.py delete mode 100644 backend/test/unit/channel/startup/test_redis_circuit_breaker.py delete mode 100644 backend/test/unit/channel/startup/test_redis_config_reload.py delete mode 100644 backend/test/unit/channel/startup/test_redis_content_filter.py delete mode 100644 backend/test/unit/channel/startup/test_redis_pubsub.py delete mode 100644 backend/test/unit/channel/startup/test_redis_rate_limiter.py delete mode 100644 backend/test/unit/channel/startup/test_redis_stream_queue.py delete mode 100644 backend/test/unit/channel/startup/test_session_factory.py delete mode 100644 backend/test/unit/channel/startup/test_session_resolver.py delete mode 100644 backend/test/unit/channel/startup/test_setup_channel.py delete mode 100644 backend/test/unit/channel/startup/test_sse_endpoint.py delete mode 100644 backend/test/unit/channel/startup/test_startup_module.py delete mode 100644 backend/test/unit/channel/startup/test_startup_tracer.py delete mode 100644 backend/test/unit/channel/startup/test_unified_message.py delete mode 100644 backend/test/unit/channel/startup/test_web_adapter.py delete mode 100644 backend/test/unit/channel/startup/test_worker_pool.py delete mode 100644 backend/test/unit/channel/startup/test_ws_manager.py diff --git a/backend/package/yuxi/channel/application/command/plugin/__init__.py b/backend/package/yuxi/channel/application/command/plugin/__init__.py new file mode 100644 index 00000000..eeab3396 --- /dev/null +++ b/backend/package/yuxi/channel/application/command/plugin/__init__.py @@ -0,0 +1,6 @@ +from yuxi.channel.application.command.plugin.activate_command import ActivateCommand as ActivateCommand +from yuxi.channel.application.command.plugin.deactivate_command import DeactivateCommand as DeactivateCommand +from yuxi.channel.application.command.plugin.reload_command import ReloadCommand as ReloadCommand +from yuxi.channel.application.command.plugin.register_command import RegisterCommand as RegisterCommand + +__all__ = ["RegisterCommand", "ActivateCommand", "DeactivateCommand", "ReloadCommand"] diff --git a/backend/package/yuxi/channel/application/command/plugin/activate_command.py b/backend/package/yuxi/channel/application/command/plugin/activate_command.py new file mode 100644 index 00000000..e073a869 --- /dev/null +++ b/backend/package/yuxi/channel/application/command/plugin/activate_command.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ActivateCommand: + plugin_id: str + config_override: dict | None = None diff --git a/backend/package/yuxi/channel/application/command/plugin/deactivate_command.py b/backend/package/yuxi/channel/application/command/plugin/deactivate_command.py new file mode 100644 index 00000000..0c0b4406 --- /dev/null +++ b/backend/package/yuxi/channel/application/command/plugin/deactivate_command.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class DeactivateCommand: + plugin_id: str diff --git a/backend/package/yuxi/channel/application/command/plugin/register_command.py b/backend/package/yuxi/channel/application/command/plugin/register_command.py new file mode 100644 index 00000000..c7dc0f13 --- /dev/null +++ b/backend/package/yuxi/channel/application/command/plugin/register_command.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class RegisterCommand: + plugin_id: str + manifest_path: str | None = None + manifest_data: dict | None = None + mode: str = "full" diff --git a/backend/package/yuxi/channel/application/command/plugin/reload_command.py b/backend/package/yuxi/channel/application/command/plugin/reload_command.py new file mode 100644 index 00000000..d16af0e2 --- /dev/null +++ b/backend/package/yuxi/channel/application/command/plugin/reload_command.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ReloadCommand: + plugin_id: str + force: bool = False diff --git a/backend/package/yuxi/channel/application/dto/plugin_dto.py b/backend/package/yuxi/channel/application/dto/plugin_dto.py new file mode 100644 index 00000000..8e4fea8b --- /dev/null +++ b/backend/package/yuxi/channel/application/dto/plugin_dto.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class PluginSummaryDTO(BaseModel): + plugin_id: str + name: str + version: str + channel_type: str + status: str + source: str + is_configured: bool + + +class PluginDetailDTO(BaseModel): + plugin_id: str + name: str + version: str + channel_type: str + status: str + source: str + is_configured: bool + capabilities: dict + config_schema: dict | None = None + registered_at: str | None = None + error: str | None = None + + +class PluginListDTO(BaseModel): + plugins: list[PluginSummaryDTO] + total: int + + +class ReloadResultDTO(BaseModel): + plugin_id: str + old_version: str + new_version: str + success: bool diff --git a/backend/package/yuxi/channel/application/event/handler/plugin_event_handler.py b/backend/package/yuxi/channel/application/event/handler/plugin_event_handler.py new file mode 100644 index 00000000..30dd5db4 --- /dev/null +++ b/backend/package/yuxi/channel/application/event/handler/plugin_event_handler.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import logging + +from yuxi.channel.domain.event.plugin.plugin_activated_event import PluginActivatedEvent +from yuxi.channel.domain.event.plugin.plugin_deactivated_event import PluginDeactivatedEvent +from yuxi.channel.domain.event.plugin.plugin_registered_event import PluginRegisteredEvent +from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent + +logger = logging.getLogger(__name__) + + +class PluginEventHandler: + def on_registered(self, event: PluginRegisteredEvent) -> None: + logger.info("plugin registered: %s (%s)", event.plugin_id, event.channel_type) + + def on_activated(self, event: PluginActivatedEvent) -> None: + logger.info("plugin activated: %s (%s)", event.plugin_id, event.channel_type) + + def on_deactivated(self, event: PluginDeactivatedEvent) -> None: + logger.info("plugin deactivated: %s (%s)", event.plugin_id, event.channel_type) + + def on_reloaded(self, event: PluginReloadedEvent) -> None: + logger.info( + "plugin reloaded: %s (%s) %s -> %s", + event.plugin_id, + event.channel_type, + event.old_version, + event.new_version, + ) diff --git a/backend/package/yuxi/channel/application/pipeline/middlewares/validation_middleware.py b/backend/package/yuxi/channel/application/pipeline/middlewares/validation_middleware.py index 2ec3430e..1df97ade 100644 --- a/backend/package/yuxi/channel/application/pipeline/middlewares/validation_middleware.py +++ b/backend/package/yuxi/channel/application/pipeline/middlewares/validation_middleware.py @@ -28,6 +28,9 @@ class ValidationMiddleware: if ctx.is_aborted: return ctx + if ctx.message.metadata.get("auth_method"): + return await call_next(ctx) + msg = ctx.message if not msg.message_id: diff --git a/backend/package/yuxi/channel/application/query/plugin/__init__.py b/backend/package/yuxi/channel/application/query/plugin/__init__.py new file mode 100644 index 00000000..f5f272cb --- /dev/null +++ b/backend/package/yuxi/channel/application/query/plugin/__init__.py @@ -0,0 +1,6 @@ +from yuxi.channel.application.query.plugin.get_plugin_manifest_query import ( + GetPluginManifestQuery as GetPluginManifestQuery, +) +from yuxi.channel.application.query.plugin.list_plugins_query import ListPluginsQuery as ListPluginsQuery + +__all__ = ["ListPluginsQuery", "GetPluginManifestQuery"] diff --git a/backend/package/yuxi/channel/application/query/plugin/get_plugin_manifest_query.py b/backend/package/yuxi/channel/application/query/plugin/get_plugin_manifest_query.py new file mode 100644 index 00000000..8aac1d2a --- /dev/null +++ b/backend/package/yuxi/channel/application/query/plugin/get_plugin_manifest_query.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class GetPluginManifestQuery: + plugin_id: str diff --git a/backend/package/yuxi/channel/application/query/plugin/list_plugins_query.py b/backend/package/yuxi/channel/application/query/plugin/list_plugins_query.py new file mode 100644 index 00000000..2338f0cd --- /dev/null +++ b/backend/package/yuxi/channel/application/query/plugin/list_plugins_query.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass +class ListPluginsQuery: + origin: str | None = None + status: str | None = None diff --git a/backend/package/yuxi/channel/application/service/binding_service.py b/backend/package/yuxi/channel/application/service/binding_service.py index f3b93d59..b44f1bbe 100644 --- a/backend/package/yuxi/channel/application/service/binding_service.py +++ b/backend/package/yuxi/channel/application/service/binding_service.py @@ -1,12 +1,20 @@ from __future__ import annotations +from yuxi.channel.domain.exception.agent_config_not_found import AgentConfigNotFoundException +from yuxi.channel.domain.exception.duplicate_binding import DuplicateBindingException from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding +from yuxi.channel.domain.port.agent_config_lookup_port import AgentConfigLookupPort from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort class BindingService: - def __init__(self, binding_repo: BindingRepositoryPort) -> None: + def __init__( + self, + binding_repo: BindingRepositoryPort, + agent_config_lookup: AgentConfigLookupPort | None = None, + ) -> None: self._repo = binding_repo + self._agent_config_lookup = agent_config_lookup async def create( self, @@ -15,12 +23,33 @@ class BindingService: account_id: str, group_id: str, agent_config_id: int, + created_by: str | None = None, ) -> ChannelBinding: + existing = await self._repo.find_binding(channel_type=channel_type, account_id=account_id, group_id=group_id) + if existing: + raise DuplicateBindingException(channel_type, account_id, group_id) + + if self._agent_config_lookup and not await self._agent_config_lookup.exists(agent_config_id): + raise AgentConfigNotFoundException(agent_config_id) + return await self._repo.create_binding( channel_type=channel_type, account_id=account_id, group_id=group_id, agent_config_id=agent_config_id, + created_by=created_by, + ) + + async def update( + self, + binding_id: int, + *, + agent_config_id: int | None = None, + is_enabled: bool | None = None, + updated_by: str | None = None, + ) -> ChannelBinding | None: + return await self._repo.update_binding( + binding_id, agent_config_id=agent_config_id, is_enabled=is_enabled, updated_by=updated_by ) async def delete(self, binding_id: int) -> bool: diff --git a/backend/package/yuxi/channel/application/service/config_service.py b/backend/package/yuxi/channel/application/service/config_service.py index 7f26d3e1..100526e0 100644 --- a/backend/package/yuxi/channel/application/service/config_service.py +++ b/backend/package/yuxi/channel/application/service/config_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import json +from yuxi.channel.application.service.auth_service import AuthService from yuxi.channel.domain.middleware.configurable import Configurable from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort from yuxi.channel.domain.service.pipeline import Pipeline @@ -17,29 +18,37 @@ class ConfigService: pipeline: Pipeline, *, channel_config: ChannelConfig | None = None, + auth_service: AuthService | None = None, ) -> None: self._config = config_data self._config_reload = config_reload self._pipeline = pipeline self._channel_config = channel_config + self._auth_service = auth_service + self._config_hash: str | None = None async def reload(self) -> tuple[list[str], str | None]: reloaded = await self._config_reload.reload() if reloaded: self._config = reloaded + new_hash = hashlib.sha256( + json.dumps(self._config, sort_keys=True).encode(), + ).hexdigest()[:8] + + if new_hash == self._config_hash: + return [], new_hash + updated = self._update_configurable_middlewares() if self._channel_config: items = await self._channel_config.on_config_updated(self._config) if items: updated.extend(items) + self._sync_auth_credentials(items) - config_hash = hashlib.sha256( - json.dumps(self._config, sort_keys=True).encode(), - ).hexdigest()[:8] - - return updated, config_hash + self._config_hash = new_hash + return updated, new_hash @property def config(self) -> dict: @@ -55,3 +64,12 @@ class ConfigService: updated.extend(items) return updated + + def _sync_auth_credentials(self, updated_items: list[str]) -> None: + if not self._auth_service: + return + if "auth_token" in updated_items or "auth_password" in updated_items: + self._auth_service.update_credentials( + token=self._channel_config.auth_token, + password=self._channel_config.auth_password, + ) diff --git a/backend/package/yuxi/channel/application/service/delivery_service.py b/backend/package/yuxi/channel/application/service/delivery_service.py index eac6aa9e..b1291cee 100644 --- a/backend/package/yuxi/channel/application/service/delivery_service.py +++ b/backend/package/yuxi/channel/application/service/delivery_service.py @@ -11,6 +11,7 @@ from yuxi.channel.domain.model.message.dispatch_result import DispatchResult, Se from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest from yuxi.channel.domain.model.session.channel_session import ChannelSession from yuxi.channel.domain.model.shared.channel_type import ChannelType +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry from yuxi.channel.domain.port.agent_port import AgentPort from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort @@ -31,12 +32,14 @@ class DeliveryService: event_publisher: EventPublisherPort, agent_port: AgentPort, *, + registry: PluginRegistry | None = None, message_log_repo: MessageLogRepositoryPort | None = None, metrics: MetricsPort | None = None, agent_timeout: float = 120.0, typing_interval: float = 3.0, ) -> None: self._adapters = adapters + self._registry = registry self._message_repo = message_repo self._outbox = outbox_repo self._events = event_publisher @@ -46,6 +49,11 @@ class DeliveryService: self._agent_timeout = agent_timeout self._typing_interval = typing_interval + def _get_adapter(self, channel_type: str) -> ChannelAdapterPort | None: + if self._registry: + return self._registry.get_adapter(channel_type) + return self._adapters.get(channel_type) + async def deliver( self, payload: dict, @@ -89,7 +97,7 @@ class DeliveryService: await self._metrics.record_worker_dispatch_duration(channel_type, time.monotonic() - start) return DispatchResult(success=True, message_id=message_id) - adapter = self._adapters.get(channel_type) + adapter = self._get_adapter(channel_type) if not adapter: if self._metrics: await self._metrics.record_worker_dispatch_total(channel_type, "no_adapter") @@ -154,7 +162,7 @@ class DeliveryService: metadata=payload.get("metadata", {}), ) - adapter = self._adapters.get(channel_type) + adapter = self._get_adapter(channel_type) typing_task = await self._start_typing(adapter, session.thread_id) try: response = await asyncio.wait_for( diff --git a/backend/package/yuxi/channel/application/service/inbound_service.py b/backend/package/yuxi/channel/application/service/inbound_service.py index 2937e93b..40712ca4 100644 --- a/backend/package/yuxi/channel/application/service/inbound_service.py +++ b/backend/package/yuxi/channel/application/service/inbound_service.py @@ -63,10 +63,12 @@ class InboundService: return try: pipeline_result = "skipped" if ctx.is_skipped else ("aborted" if ctx.is_aborted else "passed") + status = "processing" if pipeline_result == "accepted" else "completed" await self._message_log_repo.update_pipeline_result( trace_id=ctx.trace_id, message_id=ctx.message.message_id, pipeline_result=pipeline_result, + status=status, abort_reason=ctx.abort_reason if ctx.is_aborted else None, ) except Exception: diff --git a/backend/package/yuxi/channel/application/service/plugin_registry_app_service.py b/backend/package/yuxi/channel/application/service/plugin_registry_app_service.py new file mode 100644 index 00000000..795adfc1 --- /dev/null +++ b/backend/package/yuxi/channel/application/service/plugin_registry_app_service.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import logging + +from yuxi.channel.application.command.plugin.activate_command import ActivateCommand +from yuxi.channel.application.command.plugin.deactivate_command import DeactivateCommand +from yuxi.channel.application.command.plugin.reload_command import ReloadCommand +from yuxi.channel.application.dto.plugin_dto import ( + PluginDetailDTO, + PluginListDTO, + PluginSummaryDTO, + ReloadResultDTO, +) +from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent +from yuxi.channel.domain.exception.plugin_exception import ( + ActiveSessionExistsException, + InvalidPluginStateException, + PluginNotFoundException, + PluginNotConfiguredException, + PluginReloadException, +) +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginSpec +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort +from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort +from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort +from yuxi.channel.domain.service.plugin_registry_domain_service import PluginRegistryDomainService +from yuxi.channel.infrastructure.plugin.registry_impl import PluginRegistryImpl + +logger = logging.getLogger(__name__) + + +class PluginRegistryAppService: + def __init__( + self, + registry_impl: PluginRegistryImpl, + event_publisher: EventPublisherPort | None = None, + ): + self._impl = registry_impl + self._registry = registry_impl.registry + self._event_publisher = event_publisher + + def get_adapter(self, channel_type: str) -> ChannelAdapterPort | None: + return self._registry.get_adapter(channel_type) + + def get_channel_type_for_plugin(self, plugin_id: str) -> str | None: + record = self._registry.get_record(plugin_id) + return record.channel_type if record else None + + def list_plugins(self, origin: str | None = None, status: str | None = None) -> PluginListDTO: + spec = None + if origin or status: + spec = PluginSpec( + origin=origin, + status=PluginStatus(status) if status else None, + ) + records = self._registry.list_records(spec) + summaries = [ + PluginSummaryDTO( + plugin_id=r.plugin_id, + name=r.name, + version=r.version, + channel_type=r.channel_type, + status=r.status.value, + source=r.source.origin.value, + is_configured=r.is_configured, + ) + for r in records + ] + return PluginListDTO(plugins=summaries, total=len(summaries)) + + def get_plugin(self, plugin_id: str) -> PluginDetailDTO: + record = self._registry.get_record(plugin_id) + if not record: + raise PluginNotFoundException(plugin_id) + return PluginDetailDTO( + plugin_id=record.plugin_id, + name=record.name, + version=record.version, + channel_type=record.channel_type, + status=record.status.value, + source=record.source.origin.value, + is_configured=record.is_configured, + capabilities={ + "media": record.capabilities.media, + "group": record.capabilities.group, + "dm": record.capabilities.dm, + "streaming": record.capabilities.streaming, + "typing": record.capabilities.typing, + "reaction": record.capabilities.reaction, + "thread": record.capabilities.thread, + "max_text_length": record.capabilities.max_text_length, + }, + config_schema=record.config_schema, + registered_at=record.registered_at.isoformat() if record.registered_at else None, + error=record.error, + ) + + def get_manifest(self, plugin_id: str) -> dict: + record = self._registry.get_record(plugin_id) + if not record: + raise PluginNotFoundException(plugin_id) + return { + "id": record.manifest.id, + "name": record.manifest.name, + "version": record.manifest.version, + "channel_type": record.manifest.channel_type, + "capabilities": { + "media": record.manifest.capabilities.media, + "group": record.manifest.capabilities.group, + "dm": record.manifest.capabilities.dm, + "streaming": record.manifest.capabilities.streaming, + "typing": record.manifest.capabilities.typing, + "reaction": record.manifest.capabilities.reaction, + "thread": record.manifest.capabilities.thread, + "max_text_length": record.manifest.capabilities.max_text_length, + }, + "config_schema": record.manifest.config_schema, + "entry": record.manifest.entry, + "dependencies": record.manifest.dependencies, + } + + def get_config_schema(self, plugin_id: str) -> dict | None: + record = self._registry.get_record(plugin_id) + if not record: + raise PluginNotFoundException(plugin_id) + return record.config_schema + + async def activate(self, command: ActivateCommand) -> PluginDetailDTO: + record = self._registry.get_record(command.plugin_id) + if not record: + raise PluginNotFoundException(command.plugin_id) + + if not PluginRegistryDomainService.can_activate(record): + raise InvalidPluginStateException(command.plugin_id, record.status.value, "activate") + + config = command.config_override or {} + if config: + record.set_config(config) + + if not record.is_configured: + raise PluginNotConfiguredException(command.plugin_id) + + PluginRegistryDomainService.transition_to_configured(record) + + await self._registry.activate(command.plugin_id, **config) + return self.get_plugin(command.plugin_id) + + async def deactivate( + self, command: DeactivateCommand, *, session_repo: SessionRepositoryPort | None = None + ) -> PluginDetailDTO: + record = self._registry.get_record(command.plugin_id) + if not record: + raise PluginNotFoundException(command.plugin_id) + + if not PluginRegistryDomainService.can_deactivate(record): + raise InvalidPluginStateException(command.plugin_id, record.status.value, "deactivate") + + if session_repo and record.channel_type: + count = await session_repo.count_active_sessions(record.channel_type) + if count > 0: + raise ActiveSessionExistsException(command.plugin_id, record.channel_type, count) + + await self._registry.deactivate(command.plugin_id) + return self.get_plugin(command.plugin_id) + + async def reload_plugin(self, command: ReloadCommand) -> ReloadResultDTO: + old_record = self._registry.get_record(command.plugin_id) + if not old_record: + raise PluginNotFoundException(command.plugin_id) + + if not PluginRegistryDomainService.can_reload(old_record): + raise InvalidPluginStateException(command.plugin_id, old_record.status.value, "reload") + + old_version = old_record.version + channel_type = old_record.channel_type + + config = self._impl.resolve_config(old_record) + + try: + await self._registry.reload(command.plugin_id, **config) + except Exception as exc: + logger.error("reload plugin %s failed: %s", command.plugin_id, exc) + raise PluginReloadException(command.plugin_id) from exc + + new_record = self._registry.get_record(command.plugin_id) + new_version = new_record.version if new_record else old_version + + if self._event_publisher: + try: + await self._event_publisher.publish( + PluginReloadedEvent( + plugin_id=command.plugin_id, + old_version=old_version, + new_version=new_version, + channel_type=channel_type, + ) + ) + except Exception: + logger.warning("failed to publish reload event for %s", command.plugin_id) + + return ReloadResultDTO( + plugin_id=command.plugin_id, + old_version=old_version, + new_version=new_version, + success=True, + ) diff --git a/backend/package/yuxi/channel/channel_config.yaml b/backend/package/yuxi/channel/channel_config.yaml index 40a1c0b7..9ca8d37d 100644 --- a/backend/package/yuxi/channel/channel_config.yaml +++ b/backend/package/yuxi/channel/channel_config.yaml @@ -9,6 +9,7 @@ feishu: app_id: "" app_secret: "" verification_token: "" + encrypt_key: "" ws_enabled: true dingtalk: diff --git a/backend/package/yuxi/channel/channels/__init__.py b/backend/package/yuxi/channel/channels/__init__.py index ef00c180..992244a0 100644 --- a/backend/package/yuxi/channel/channels/__init__.py +++ b/backend/package/yuxi/channel/channels/__init__.py @@ -1,4 +1,4 @@ -import yuxi.channel.channels.feishu -import yuxi.channel.channels.dingtalk -import yuxi.channel.channels.web -import yuxi.channel.channels.hooks +import yuxi.channel.channels.feishu # noqa: F401 +import yuxi.channel.channels.dingtalk # noqa: F401 +import yuxi.channel.channels.web # noqa: F401 +import yuxi.channel.channels.hooks # noqa: F401 diff --git a/backend/package/yuxi/channel/channels/_registry.py b/backend/package/yuxi/channel/channels/_registry.py index 3c777423..8401361e 100644 --- a/backend/package/yuxi/channel/channels/_registry.py +++ b/backend/package/yuxi/channel/channels/_registry.py @@ -1,16 +1,63 @@ from __future__ import annotations +from collections.abc import Callable from typing import TYPE_CHECKING if TYPE_CHECKING: from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort + from yuxi.channel.domain.port.channel_request_verifier_port import ChannelRequestVerifierPort -_channel_adapters: dict[str, type[ChannelAdapterPort]] = {} +_channel_registry: dict[str, dict] = {} +_compat_registry: dict[str, type[ChannelAdapterPort]] = {} -def register_channel(name: str, adapter_cls: type[ChannelAdapterPort]) -> None: - _channel_adapters[name] = adapter_cls +def register_channel( + name: str, + adapter_cls: type[ChannelAdapterPort], + *, + config_cls: type | None = None, + verifier_factory: Callable[[dict], ChannelRequestVerifierPort] | None = None, + env_mapping: dict[str, str] | None = None, + infra_dependencies: list[str] | None = None, +) -> None: + _compat_registry[name] = adapter_cls + _channel_registry[name] = { + "adapter_cls": adapter_cls, + "config_cls": config_cls, + "verifier_factory": verifier_factory, + "env_mapping": env_mapping or {}, + "infra_dependencies": infra_dependencies or [], + } + + try: + from yuxi.channel.infrastructure.plugin.registry_impl import get_registry + + registry = get_registry() + if registry: + registry.register_builtin_adapter(name, adapter_cls) + except ImportError: + pass + + +def get_channel_meta(name: str) -> dict: + return _channel_registry.get(name, {}) + + +def get_all_meta() -> dict[str, dict]: + return dict(_channel_registry) def get_registered_channels() -> dict[str, type[ChannelAdapterPort]]: - return dict(_channel_adapters) + try: + from yuxi.channel.infrastructure.plugin.registry_impl import get_registry + + registry = get_registry() + if registry: + return registry.get_registered_adapter_classes() + except ImportError: + pass + return dict(_compat_registry) + + +def get_compat_registry() -> dict[str, type[ChannelAdapterPort]]: + return dict(_compat_registry) diff --git a/backend/package/yuxi/channel/channels/dingtalk/__init__.py b/backend/package/yuxi/channel/channels/dingtalk/__init__.py index 460bb7bb..a20abafd 100644 --- a/backend/package/yuxi/channel/channels/dingtalk/__init__.py +++ b/backend/package/yuxi/channel/channels/dingtalk/__init__.py @@ -1,4 +1,18 @@ -from yuxi.channel.channels.dingtalk.adapter import DingTalkAdapter from yuxi.channel.channels._registry import register_channel +from yuxi.channel.channels.dingtalk.adapter import DingTalkAdapter +from yuxi.channel.channels.dingtalk.config import DingTalkConfig -register_channel("dingtalk", DingTalkAdapter) + +def _create_dingtalk_verifier(config: dict): + from yuxi.channel.channels.dingtalk.verifier import DingTalkRequestVerifier + + return DingTalkRequestVerifier(sign_secret=config.get("client_secret", "")) + + +register_channel( + "dingtalk", + DingTalkAdapter, + config_cls=DingTalkConfig, + verifier_factory=_create_dingtalk_verifier, + env_mapping={"DINGTALK_CLIENT_ID": "client_id", "DINGTALK_CLIENT_SECRET": "client_secret"}, +) diff --git a/backend/package/yuxi/channel/channels/dingtalk/adapter.py b/backend/package/yuxi/channel/channels/dingtalk/adapter.py index 6ce199a1..c6f3955b 100644 --- a/backend/package/yuxi/channel/channels/dingtalk/adapter.py +++ b/backend/package/yuxi/channel/channels/dingtalk/adapter.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from yuxi.channel.channels.dingtalk.config import DingTalkConfig from yuxi.channel.channels.dingtalk.outbound import DINGTALK_CAPABILITIES, DingTalkOutbound from yuxi.channel.channels.dingtalk.translator import DingTalkTranslator from yuxi.channel.channels.dingtalk.ws_connection import DingTalkWsConnection @@ -9,6 +10,7 @@ from yuxi.channel.domain.model.message.dispatch_result import SendResult from yuxi.channel.domain.model.message.unified_message import UnifiedMessage from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities from yuxi.channel.domain.model.shared.channel_type import ChannelType +from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort logger = logging.getLogger(__name__) @@ -54,7 +56,7 @@ class DingTalkAdapter: return self._ws @property - def route_contributor(self) -> _DingTalkRouteContributor: + def route_contributor(self) -> ChannelRouteContributor | None: return _DingTalkRouteContributor() @classmethod @@ -65,6 +67,14 @@ class DingTalkAdapter: "ws_enabled": True, } + @classmethod + def from_config(cls, config: DingTalkConfig) -> DingTalkAdapter: + return cls( + client_id=config.client_id, + client_secret=config.client_secret, + ws_enabled=config.ws_enabled, + ) + async def open(self) -> None: await self._outbound.start() self._opened = True @@ -73,7 +83,7 @@ class DingTalkAdapter: self._opened = False async def receive_message(self, raw: dict) -> UnifiedMessage: - return DingTalkTranslator.translate_event(raw) + return DingTalkTranslator.translate(raw) async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult: try: diff --git a/backend/package/yuxi/channel/channels/dingtalk/config.py b/backend/package/yuxi/channel/channels/dingtalk/config.py index dc611548..1671ca88 100644 --- a/backend/package/yuxi/channel/channels/dingtalk/config.py +++ b/backend/package/yuxi/channel/channels/dingtalk/config.py @@ -1,6 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field + +from yuxi.channel.channels.dingtalk.outbound import DINGTALK_CAPABILITIES +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities @dataclass @@ -8,3 +11,4 @@ class DingTalkConfig: client_id: str = "" client_secret: str = "" ws_enabled: bool = True + capabilities: ChannelCapabilities = field(default_factory=lambda: DINGTALK_CAPABILITIES) diff --git a/backend/package/yuxi/channel/channels/dingtalk/routes.py b/backend/package/yuxi/channel/channels/dingtalk/routes.py index 4e2d7968..0591ec71 100644 --- a/backend/package/yuxi/channel/channels/dingtalk/routes.py +++ b/backend/package/yuxi/channel/channels/dingtalk/routes.py @@ -1,58 +1,61 @@ from __future__ import annotations +import json import logging from uuid import uuid4 -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, Request from fastapi.responses import JSONResponse -from yuxi.channel.channels.dingtalk.translator import DingTalkTranslator - logger = logging.getLogger(__name__) router = APIRouter() -@router.post("/channel/dingtalk/callback") -async def dingtalk_callback(request: Request): - body = await request.json() +@router.post("/callback") +async def dingtalk_callback( + request: Request, + body: bytes = Body(..., max_length=256 * 1024), +): + try: + raw = json.loads(body) + except json.JSONDecodeError: + return JSONResponse(status_code=202, content={"status": "accepted"}) from yuxi.channel.container import get_channel container = get_channel(request) - if not container: - raise HTTPException(status_code=503, detail="channel not initialized") adapter = container.adapters.get("dingtalk") if not adapter: return JSONResponse(status_code=202, content={"status": "accepted"}) + verifier = container.verifiers.get("dingtalk") + verify_result = None + if verifier and verifier.enabled: + verify_result = await verifier.verify(body, dict(request.headers)) + if not verify_result.passed: + logger.warning("dingtalk verify failed: %s", verify_result.reason) + if container.metrics: + await container.metrics.record_verification_failed("dingtalk", verify_result.reason) + return JSONResponse(status_code=202, content={"status": "accepted"}) + try: - message = DingTalkTranslator.translate_event(body) + message = await adapter.receive_message(raw) except Exception: - logger.exception("dingtalk event parse failed, body=%s", body) + logger.exception("dingtalk event parse failed") return JSONResponse(status_code=202, content={"status": "accepted"}) - msg_id = body.get("msgId", str(uuid4())) + msg_id = raw.get("msgId", str(uuid4())) message.metadata["trace_id"] = msg_id message.metadata["idempotency_key"] = msg_id message.metadata["client_ip"] = request.client.host if request.client else "unknown" + if verify_result: + message.metadata["auth_method"] = verify_result.method result = await container.inbound_service.submit(message, channel_type="dingtalk", trace_id=msg_id) if result.is_aborted: logger.warning("dingtalk message aborted: %s [%s]", result.abort_reason, msg_id) - if container.event_publisher: - from yuxi.channel.domain.event.message_blocked import MessageBlocked - - await container.event_publisher.publish( - MessageBlocked( - message_id=message.message_id, - channel_type="dingtalk", - session_id="", - reason=result.abort_reason or "pipeline_aborted", - trace_id=msg_id, - ) - ) return JSONResponse(status_code=202, content={"status": "accepted", "trace_id": msg_id}) diff --git a/backend/package/yuxi/channel/channels/dingtalk/translator.py b/backend/package/yuxi/channel/channels/dingtalk/translator.py index acb0a2e7..14cf443a 100644 --- a/backend/package/yuxi/channel/channels/dingtalk/translator.py +++ b/backend/package/yuxi/channel/channels/dingtalk/translator.py @@ -7,7 +7,7 @@ from yuxi.channel.domain.model.shared.channel_type import ChannelType class DingTalkTranslator: @staticmethod - def translate_event(raw: dict) -> UnifiedMessage: + def translate(raw: dict) -> UnifiedMessage: event = raw.get("event", {}) message = event.get("message", {}) sender = event.get("sender", {}) @@ -29,3 +29,7 @@ class DingTalkTranslator: }, raw_payload=raw, ) + + @staticmethod + def translate_event(raw: dict) -> UnifiedMessage: + return DingTalkTranslator.translate(raw) diff --git a/backend/package/yuxi/channel/channels/dingtalk/verifier.py b/backend/package/yuxi/channel/channels/dingtalk/verifier.py new file mode 100644 index 00000000..a26b209f --- /dev/null +++ b/backend/package/yuxi/channel/channels/dingtalk/verifier.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import time + +from yuxi.channel.domain.port.channel_request_verifier_port import VerifyResult + +logger = logging.getLogger(__name__) + +_TIMESTAMP_TOLERANCE_SECONDS = 3600 + + +class DingTalkRequestVerifier: + def __init__(self, *, sign_secret: str = "") -> None: + self._sign_secret = sign_secret + + @property + def channel_type(self) -> str: + return "dingtalk" + + @property + def enabled(self) -> bool: + return bool(self._sign_secret) + + async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult: + if not self._sign_secret: + return VerifyResult(passed=True, method="dingtalk_none", reason="no_secret_configured") + + timestamp = headers.get("timestamp", "") + sign = headers.get("sign", "") + + if not timestamp or not sign: + return VerifyResult(passed=False, method="dingtalk_sign", reason="missing_sign_headers") + + try: + ts = int(timestamp) + except (ValueError, TypeError): + return VerifyResult(passed=False, method="dingtalk_sign", reason="invalid_timestamp") + + if abs(time.time() - ts / 1000) > _TIMESTAMP_TOLERANCE_SECONDS: + return VerifyResult(passed=False, method="dingtalk_sign", reason="timestamp_expired") + + string_to_sign = f"{timestamp}\n{self._sign_secret}" + expected = base64.b64encode( + hmac.new(self._sign_secret.encode(), string_to_sign.encode(), hashlib.sha256).digest() + ).decode() + if not hmac.compare_digest(sign, expected): + return VerifyResult(passed=False, method="dingtalk_sign", reason="sign_mismatch") + + return VerifyResult(passed=True, method="dingtalk_sign") diff --git a/backend/package/yuxi/channel/channels/feishu/__init__.py b/backend/package/yuxi/channel/channels/feishu/__init__.py index 3b1903ce..560cb047 100644 --- a/backend/package/yuxi/channel/channels/feishu/__init__.py +++ b/backend/package/yuxi/channel/channels/feishu/__init__.py @@ -1,4 +1,18 @@ from yuxi.channel.channels._registry import register_channel from yuxi.channel.channels.feishu.adapter import FeishuAdapter +from yuxi.channel.channels.feishu.config import FeishuConfig -register_channel("feishu", FeishuAdapter) + +def _create_feishu_verifier(config: dict): + from yuxi.channel.channels.feishu.verifier import FeishuRequestVerifier + + return FeishuRequestVerifier(encrypt_key=config.get("encrypt_key", "")) + + +register_channel( + "feishu", + FeishuAdapter, + config_cls=FeishuConfig, + verifier_factory=_create_feishu_verifier, + env_mapping={"FEISHU_APP_ID": "app_id", "FEISHU_APP_SECRET": "app_secret"}, +) diff --git a/backend/package/yuxi/channel/channels/feishu/adapter.py b/backend/package/yuxi/channel/channels/feishu/adapter.py index 2ef3da1a..786b318d 100644 --- a/backend/package/yuxi/channel/channels/feishu/adapter.py +++ b/backend/package/yuxi/channel/channels/feishu/adapter.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from yuxi.channel.channels.feishu.config import FeishuConfig from yuxi.channel.channels.feishu.outbound import FEISHU_CAPABILITIES, FeishuOutbound from yuxi.channel.channels.feishu.translator import FeishuTranslator from yuxi.channel.channels.feishu.ws_connection import FeishuWsConnection @@ -9,7 +10,7 @@ from yuxi.channel.domain.model.message.dispatch_result import SendResult from yuxi.channel.domain.model.message.unified_message import UnifiedMessage from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities from yuxi.channel.domain.model.shared.channel_type import ChannelType -from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor +from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort logger = logging.getLogger(__name__) @@ -30,10 +31,12 @@ class FeishuAdapter: app_id: str = "", app_secret: str = "", verification_token: str = "", + encrypt_key: str = "", ws_enabled: bool = True, ) -> None: self._outbound = FeishuOutbound(app_id=app_id, app_secret=app_secret) self._verification_token = verification_token + self._encrypt_key = encrypt_key self._ws_enabled = ws_enabled self._ws: FeishuWsConnection | None = None if ws_enabled and app_id and app_secret: @@ -66,9 +69,20 @@ class FeishuAdapter: "app_id": "", "app_secret": "", "verification_token": "", + "encrypt_key": "", "ws_enabled": True, } + @classmethod + def from_config(cls, config: FeishuConfig) -> FeishuAdapter: + return cls( + app_id=config.app_id, + app_secret=config.app_secret, + verification_token=config.verification_token, + encrypt_key=config.encrypt_key, + ws_enabled=config.ws_enabled, + ) + async def open(self) -> None: await self._outbound.start() self._opened = True @@ -77,7 +91,7 @@ class FeishuAdapter: self._opened = False async def receive_message(self, raw: dict) -> UnifiedMessage: - return FeishuTranslator.translate_event(raw) + return FeishuTranslator.translate(raw) async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult: try: diff --git a/backend/package/yuxi/channel/channels/feishu/config.py b/backend/package/yuxi/channel/channels/feishu/config.py index eb020176..ffa8e79a 100644 --- a/backend/package/yuxi/channel/channels/feishu/config.py +++ b/backend/package/yuxi/channel/channels/feishu/config.py @@ -1,6 +1,9 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field + +from yuxi.channel.channels.feishu.outbound import FEISHU_CAPABILITIES +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities @dataclass @@ -8,4 +11,6 @@ class FeishuConfig: app_id: str = "" app_secret: str = "" verification_token: str = "" + encrypt_key: str = "" ws_enabled: bool = True + capabilities: ChannelCapabilities = field(default_factory=lambda: FEISHU_CAPABILITIES) diff --git a/backend/package/yuxi/channel/channels/feishu/routes.py b/backend/package/yuxi/channel/channels/feishu/routes.py index c0243ad8..58bdf1f9 100644 --- a/backend/package/yuxi/channel/channels/feishu/routes.py +++ b/backend/package/yuxi/channel/channels/feishu/routes.py @@ -1,75 +1,80 @@ from __future__ import annotations +import json import logging from uuid import uuid4 -from fastapi import APIRouter, HTTPException, Request - -from yuxi.channel.channels.feishu.translator import FeishuTranslator +from fastapi import APIRouter, Body, HTTPException, Request logger = logging.getLogger(__name__) router = APIRouter() -@router.post("/channel/feishu/event") -async def feishu_event(request: Request): - body = await request.json() +@router.post("/event") +async def feishu_event( + request: Request, + body: bytes = Body(..., max_length=256 * 1024), +): + try: + raw = json.loads(body) + except json.JSONDecodeError: + return {"code": 0} - if body.get("type") == "url_verification": - return {"challenge": body.get("challenge", "")} + if raw.get("type") == "url_verification": + return {"challenge": raw.get("challenge", "")} from yuxi.channel.container import get_channel container = get_channel(request) - if not container: - raise HTTPException(status_code=503, detail="channel not initialized") adapter = container.adapters.get("feishu") if not adapter: logger.warning("feishu adapter not available, dropping event") return {"code": 0} + verifier = container.verifiers.get("feishu") + verify_result = None + if verifier and verifier.enabled: + verify_result = await verifier.verify(body, dict(request.headers)) + if not verify_result.passed: + logger.warning("feishu verify failed: %s", verify_result.reason) + if container.metrics: + await container.metrics.record_verification_failed("feishu", verify_result.reason) + return {"code": 0} + try: - message = FeishuTranslator.translate_event(body) + message = await adapter.receive_message(raw) except Exception: - logger.exception("feishu event parse failed, body=%s", body) + logger.exception("feishu event parse failed") return {"code": 0} - trace_id = body.get("header", {}).get("event_id", str(uuid4())) + trace_id = raw.get("header", {}).get("event_id", str(uuid4())) message.metadata["trace_id"] = trace_id message.metadata["idempotency_key"] = trace_id message.metadata["client_ip"] = request.client.host if request.client else "unknown" + if verify_result: + message.metadata["auth_method"] = verify_result.method result = await container.inbound_service.submit(message, channel_type="feishu", trace_id=trace_id) if result.is_aborted: logger.warning("feishu message aborted: %s [%s]", result.abort_reason, trace_id) - if container.event_publisher: - from yuxi.channel.domain.event.message_blocked import MessageBlocked - - await container.event_publisher.publish( - MessageBlocked( - message_id=message.message_id, - channel_type="feishu", - session_id="", - reason=result.abort_reason or "pipeline_aborted", - trace_id=trace_id, - ) - ) return {"code": 0} -@router.get("/channel/feishu/verify") +@router.get("/verify") async def feishu_verify(challenge: str, token: str, request: Request): from yuxi.channel.container import get_channel container = get_channel(request) - if not container: - raise HTTPException(status_code=503, detail="channel not initialized") - expected_token = container.channel_config.feishu_verification_token + adapter = container.adapters.get("feishu") + if not adapter: + raise HTTPException(status_code=503) + + expected_token = getattr(adapter, "_verification_token", "") if expected_token and token != expected_token: raise HTTPException(status_code=403, detail="verification token mismatch") diff --git a/backend/package/yuxi/channel/channels/feishu/translator.py b/backend/package/yuxi/channel/channels/feishu/translator.py index 7f87089e..01daa6c0 100644 --- a/backend/package/yuxi/channel/channels/feishu/translator.py +++ b/backend/package/yuxi/channel/channels/feishu/translator.py @@ -10,7 +10,7 @@ from yuxi.channel.domain.model.shared.channel_type import ChannelType class FeishuTranslator: @staticmethod - def translate_event(raw: dict) -> UnifiedMessage: + def translate(raw: dict) -> UnifiedMessage: event = raw.get("event", {}) message = event.get("message", {}) sender = event.get("sender", {}) @@ -37,6 +37,10 @@ class FeishuTranslator: raw_payload=raw, ) + @staticmethod + def translate_event(raw: dict) -> UnifiedMessage: + return FeishuTranslator.translate(raw) + def _extract_text(message: dict) -> str: content_raw = message.get("content", "") diff --git a/backend/package/yuxi/channel/channels/feishu/verifier.py b/backend/package/yuxi/channel/channels/feishu/verifier.py new file mode 100644 index 00000000..1c61a700 --- /dev/null +++ b/backend/package/yuxi/channel/channels/feishu/verifier.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import time + +from yuxi.channel.domain.port.channel_request_verifier_port import VerifyResult + +logger = logging.getLogger(__name__) + +_TIMESTAMP_TOLERANCE_SECONDS = 3600 + + +class FeishuRequestVerifier: + def __init__(self, *, encrypt_key: str = "") -> None: + self._encrypt_key = encrypt_key + + @property + def channel_type(self) -> str: + return "feishu" + + @property + def enabled(self) -> bool: + return bool(self._encrypt_key) + + async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult: + if not self._encrypt_key: + return VerifyResult(passed=True, method="feishu_none", reason="no_encrypt_key_configured") + + signature = headers.get("x-lark-signature", "") + timestamp = headers.get("x-lark-request-timestamp", "") + nonce = headers.get("x-lark-request-nonce", "") + + if not signature or not timestamp: + return VerifyResult(passed=False, method="feishu_signature", reason="missing_signature_headers") + + try: + ts = int(timestamp) + except (ValueError, TypeError): + return VerifyResult(passed=False, method="feishu_signature", reason="invalid_timestamp") + + if abs(time.time() - ts) > _TIMESTAMP_TOLERANCE_SECONDS: + return VerifyResult(passed=False, method="feishu_signature", reason="timestamp_expired") + + content = f"{timestamp}{nonce}".encode() + body + expected = base64.b64encode(hmac.new(self._encrypt_key.encode(), content, hashlib.sha256).digest()).decode() + if not hmac.compare_digest(signature, expected): + return VerifyResult(passed=False, method="feishu_signature", reason="signature_mismatch") + + return VerifyResult(passed=True, method="feishu_signature") diff --git a/backend/package/yuxi/channel/channels/hooks/__init__.py b/backend/package/yuxi/channel/channels/hooks/__init__.py index 49145497..2ccf3ec7 100644 --- a/backend/package/yuxi/channel/channels/hooks/__init__.py +++ b/backend/package/yuxi/channel/channels/hooks/__init__.py @@ -1,4 +1,23 @@ from yuxi.channel.channels._registry import register_channel from yuxi.channel.channels.hooks.adapter import HooksAdapter +from yuxi.channel.channels.hooks.config import HookMapping, HooksConfig -register_channel("hooks", HooksAdapter) + +def _create_hooks_verifier(config: dict): + from yuxi.channel.channels.hooks.verifier import HooksRequestVerifier + + mappings_raw = config.get("mappings", []) + mappings: dict[tuple[str, str], HookMapping] = {} + for m in mappings_raw: + mapping = HookMapping(**m) if isinstance(m, dict) else m + mappings[(mapping.match_path, mapping.match_source)] = mapping + return HooksRequestVerifier(mappings=mappings) + + +register_channel( + "hooks", + HooksAdapter, + config_cls=HooksConfig, + verifier_factory=_create_hooks_verifier, + infra_dependencies=["hook_mappings"], +) diff --git a/backend/package/yuxi/channel/channels/hooks/adapter.py b/backend/package/yuxi/channel/channels/hooks/adapter.py index 7c19b998..678dd3c3 100644 --- a/backend/package/yuxi/channel/channels/hooks/adapter.py +++ b/backend/package/yuxi/channel/channels/hooks/adapter.py @@ -2,28 +2,18 @@ from __future__ import annotations import logging -from yuxi.channel.channels.hooks.config import HookMapping +from yuxi.channel.channels.hooks.config import HookMapping, HooksConfig +from yuxi.channel.channels.hooks.outbound import HOOKS_CAPABILITIES from yuxi.channel.channels.hooks.translator import HooksTranslator from yuxi.channel.domain.model.message.dispatch_result import SendResult from yuxi.channel.domain.model.message.unified_message import UnifiedMessage from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities from yuxi.channel.domain.model.shared.channel_type import ChannelType -from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor +from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort logger = logging.getLogger(__name__) -HOOKS_CAPABILITIES = ChannelCapabilities( - media=False, - group=False, - dm=True, - streaming=False, - typing=False, - reaction=False, - thread=False, - max_text_length=32768, -) - class _HooksRouteContributor: @property @@ -64,6 +54,27 @@ class HooksAdapter: "mappings": [], } + @classmethod + def from_config(cls, config: HooksConfig) -> HooksAdapter: + mapping_dicts = [ + { + "match_path": m.match_path, + "match_source": m.match_source, + "default_agent_id": m.default_agent_id, + "allowed_agent_ids": m.allowed_agent_ids, + "default_session_key": m.default_session_key, + "allow_request_session_key": m.allow_request_session_key, + "allowed_session_key_prefixes": m.allowed_session_key_prefixes, + "session_key_strategy": m.session_key_strategy, + "channel": m.channel, + "deliver": m.deliver, + "max_body_bytes": m.max_body_bytes, + "secret": m.secret, + } + for m in config.mappings + ] + return cls(mappings=mapping_dicts) + def match_hook(self, path: str, source: str = "*") -> HookMapping | None: exact = self._mappings.get((path, source)) if exact: @@ -83,7 +94,8 @@ class HooksAdapter: mapping = self.match_hook(path, source) if not mapping: raise ValueError(f"no hook mapping for path={path}, source={source}") - return HooksTranslator.translate(raw, mapping) + raw["_hook_mapping"] = mapping + return HooksTranslator.translate(raw) async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult: logger.info( diff --git a/backend/package/yuxi/channel/channels/hooks/config.py b/backend/package/yuxi/channel/channels/hooks/config.py index 29db0fa4..6f8d9046 100644 --- a/backend/package/yuxi/channel/channels/hooks/config.py +++ b/backend/package/yuxi/channel/channels/hooks/config.py @@ -2,6 +2,9 @@ from __future__ import annotations from dataclasses import dataclass, field +from yuxi.channel.channels.hooks.outbound import HOOKS_CAPABILITIES +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + @dataclass class HookMapping: @@ -22,3 +25,4 @@ class HookMapping: @dataclass class HooksConfig: mappings: list[HookMapping] = field(default_factory=list) + capabilities: ChannelCapabilities = field(default_factory=lambda: HOOKS_CAPABILITIES) diff --git a/backend/package/yuxi/channel/channels/hooks/outbound.py b/backend/package/yuxi/channel/channels/hooks/outbound.py new file mode 100644 index 00000000..5f9ba5a7 --- /dev/null +++ b/backend/package/yuxi/channel/channels/hooks/outbound.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +import logging + +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + +logger = logging.getLogger(__name__) + +HOOKS_CAPABILITIES = ChannelCapabilities( + media=False, + group=False, + dm=True, + streaming=False, + typing=False, + reaction=False, + thread=False, + max_text_length=32768, +) + + +class HooksOutbound: + async def send_text(self, session_id: str, text: str, **kwargs) -> bool: + logger.info("hooks send to %s (fire-and-forget)", session_id) + return True + + async def send_typing(self, session_id: str) -> None: + pass + + async def send_media(self, session_id: str, *, url: str, media_type: str, **kwargs) -> bool: + return True diff --git a/backend/package/yuxi/channel/channels/hooks/routes.py b/backend/package/yuxi/channel/channels/hooks/routes.py index 94b6ca6d..bd084128 100644 --- a/backend/package/yuxi/channel/channels/hooks/routes.py +++ b/backend/package/yuxi/channel/channels/hooks/routes.py @@ -1,60 +1,23 @@ from __future__ import annotations -import hashlib -import hmac import json import logging from uuid import uuid4 from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse -from pydantic import BaseModel -from yuxi.channel.channels.hooks.translator import HooksTranslator from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP +from yuxi.channel.interfaces.rest.dto.accepted_response import AcceptedResponse +from yuxi.channel.interfaces.rest.dto.error_response import ErrorResponse +from yuxi.channel.interfaces.rest.dto.idempotency import resolve_idempotency_key logger = logging.getLogger(__name__) router = APIRouter() -class ErrorResponse(BaseModel): - error: str - code: str - detail: dict | None = None - trace_id: str | None = None - - -class AcceptedResponse(BaseModel): - trace_id: str - status: str = "accepted" - message_id: str | None = None - - -def _verify_hook_auth(mapping, request: Request, body: bytes) -> None: - if not mapping.secret: - return - - auth_header = request.headers.get("authorization", "") - if auth_header.startswith("Bearer "): - token = auth_header[7:] - if hmac.compare_digest(token, mapping.secret): - return - raise HTTPException(status_code=401, detail="invalid bearer token") - - signature = request.headers.get("x-signature-256") or request.headers.get("x-hub-signature-256") - if signature: - if signature.startswith("sha256="): - signature = signature[7:] - expected = hmac.new(mapping.secret.encode(), body, hashlib.sha256).hexdigest() - if hmac.compare_digest(signature, expected): - return - raise HTTPException(status_code=401, detail="invalid signature") - - raise HTTPException(status_code=401, detail="authentication required") - - -@router.post("/channel/hooks/{match_path:path}", response_model=AcceptedResponse) +@router.post("/{match_path:path}", response_model=AcceptedResponse) async def receive_hook( match_path: str, request: Request, @@ -63,41 +26,54 @@ async def receive_hook( from yuxi.channel.container import get_channel container = get_channel(request) - if not container: - raise HTTPException(status_code=503, detail="channel not initialized") adapter = container.adapters.get("hooks") if not adapter: raise HTTPException(status_code=503, detail="hooks adapter not available") + verifier = container.verifiers.get("hooks") + verify_result = None + if verifier and verifier.enabled: + headers = dict(request.headers) + headers["x-hook-path"] = match_path + headers["x-hook-source"] = request.headers.get("X-Hook-Source", "*") + verify_result = await verifier.verify(body, headers) + if not verify_result.passed: + if verify_result.reason == "no_hook_mapping": + raise HTTPException(status_code=404, detail=verify_result.reason) + raise HTTPException(status_code=401, detail=verify_result.reason) + mapping = adapter.match_hook(match_path, request.headers.get("X-Hook-Source", "*")) - if not mapping: - raise HTTPException(status_code=404, detail=f"no hook mapping for path: {match_path}") - - try: - if container.metrics: - await container.metrics.record_hooks_received(match_path) - except Exception: - logger.debug("failed to record hooks_received metric") - - _verify_hook_auth(mapping, request, body) - - if len(body) > mapping.max_body_bytes: + if mapping and len(body) > mapping.max_body_bytes: raise HTTPException(status_code=413, detail="payload too large") + if container.metrics: + try: + await container.metrics.record_hooks_received(match_path) + except Exception: + logger.debug("failed to record hooks_received metric") + try: raw = json.loads(body) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="invalid JSON") - trace_id = raw.get("trace_id", request.headers.get("x-trace-id", str(uuid4()))) - idempotency_key = request.headers.get("idempotency-key") + raw["match_path"] = match_path + raw["match_source"] = request.headers.get("X-Hook-Source", "*") - message = HooksTranslator.translate(raw, mapping) + try: + message = await adapter.receive_message(raw) + except Exception: + logger.exception("hooks message parse failed") + raise HTTPException(status_code=400, detail="message parse failed") + + trace_id = raw.get("trace_id", request.headers.get("x-trace-id", str(uuid4()))) + idempotency_key = resolve_idempotency_key(raw, dict(request.headers), "hooks") message.metadata["trace_id"] = trace_id - if idempotency_key: - message.metadata["idempotency_key"] = idempotency_key + message.metadata["idempotency_key"] = idempotency_key message.metadata["client_ip"] = request.client.host if request.client else "unknown" + if verify_result: + message.metadata["auth_method"] = verify_result.method result = await container.inbound_service.submit(message, channel_type="hooks", trace_id=trace_id) @@ -113,21 +89,12 @@ async def receive_hook( ).model_dump(), ) - if result.is_skipped: - return JSONResponse( - status_code=202, - content=AcceptedResponse( - trace_id=trace_id, - status="skipped", - message_id=message.message_id, - ).model_dump(), - ) - + status = "skipped" if result.is_skipped else "accepted" return JSONResponse( status_code=202, content=AcceptedResponse( trace_id=trace_id, - status="accepted", + status=status, message_id=message.message_id, ).model_dump(), ) diff --git a/backend/package/yuxi/channel/channels/hooks/translator.py b/backend/package/yuxi/channel/channels/hooks/translator.py index 15369eed..e3a94fcb 100644 --- a/backend/package/yuxi/channel/channels/hooks/translator.py +++ b/backend/package/yuxi/channel/channels/hooks/translator.py @@ -1,6 +1,5 @@ from __future__ import annotations -from yuxi.channel.channels.hooks.config import HookMapping from yuxi.channel.domain.model.message.peer import Peer from yuxi.channel.domain.model.message.unified_message import UnifiedMessage from yuxi.channel.domain.model.shared.channel_type import ChannelType @@ -8,7 +7,11 @@ from yuxi.channel.domain.model.shared.channel_type import ChannelType class HooksTranslator: @staticmethod - def translate(raw: dict, mapping: HookMapping) -> UnifiedMessage: + def translate(raw: dict) -> UnifiedMessage: + mapping = raw.pop("_hook_mapping", None) + if not mapping: + raise ValueError("no hook mapping in raw") + agent_id = raw.get("agent_id", mapping.default_agent_id) if not mapping.allowed_agent_ids or agent_id not in mapping.allowed_agent_ids: agent_id = mapping.default_agent_id diff --git a/backend/package/yuxi/channel/channels/hooks/verifier.py b/backend/package/yuxi/channel/channels/hooks/verifier.py new file mode 100644 index 00000000..106dba35 --- /dev/null +++ b/backend/package/yuxi/channel/channels/hooks/verifier.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging + +from yuxi.channel.channels.hooks.config import HookMapping +from yuxi.channel.domain.port.channel_request_verifier_port import VerifyResult + +logger = logging.getLogger(__name__) + + +class HooksRequestVerifier: + def __init__(self, *, mappings: dict[tuple[str, str], HookMapping]) -> None: + self._mappings = mappings + + @property + def channel_type(self) -> str: + return "hooks" + + @property + def enabled(self) -> bool: + return any(m.secret for m in self._mappings.values()) + + async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult: + path = headers.get("x-hook-path", "") + source = headers.get("x-hook-source", "*") + mapping = self._mappings.get((path, source)) or self._mappings.get((path, "*")) + if not mapping: + return VerifyResult(passed=False, method="hooks_path", reason="no_hook_mapping") + + if not mapping.secret: + return VerifyResult(passed=True, method="hooks_none", reason="no_secret_configured") + + auth_header = headers.get("authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + if hmac.compare_digest(token, mapping.secret): + return VerifyResult(passed=True, method="hooks_bearer") + return VerifyResult(passed=False, method="hooks_bearer", reason="invalid_bearer_token") + + signature = headers.get("x-signature-256") or headers.get("x-hub-signature-256") + if signature: + if signature.startswith("sha256="): + signature = signature[7:] + expected = hmac.new(mapping.secret.encode(), body, hashlib.sha256).hexdigest() + if hmac.compare_digest(signature, expected): + return VerifyResult(passed=True, method="hooks_hmac") + return VerifyResult(passed=False, method="hooks_hmac", reason="invalid_signature") + + return VerifyResult(passed=False, method="hooks_auth", reason="authentication_required") diff --git a/backend/package/yuxi/channel/channels/web/__init__.py b/backend/package/yuxi/channel/channels/web/__init__.py index 9681c124..55b26b39 100644 --- a/backend/package/yuxi/channel/channels/web/__init__.py +++ b/backend/package/yuxi/channel/channels/web/__init__.py @@ -1,4 +1,18 @@ from yuxi.channel.channels._registry import register_channel from yuxi.channel.channels.web.adapter import WebAdapter +from yuxi.channel.channels.web.config import WebConfig -register_channel("web", WebAdapter) + +def _create_web_verifier(config: dict): + from yuxi.channel.channels.web.verifier import WebRequestVerifier + + return WebRequestVerifier(auth_token=config.get("auth_token", "")) + + +register_channel( + "web", + WebAdapter, + config_cls=WebConfig, + verifier_factory=_create_web_verifier, + infra_dependencies=["sse_push"], +) diff --git a/backend/package/yuxi/channel/channels/web/adapter.py b/backend/package/yuxi/channel/channels/web/adapter.py index 8c4ff893..61de49a3 100644 --- a/backend/package/yuxi/channel/channels/web/adapter.py +++ b/backend/package/yuxi/channel/channels/web/adapter.py @@ -2,13 +2,14 @@ from __future__ import annotations import logging +from yuxi.channel.channels.web.config import WebConfig from yuxi.channel.channels.web.outbound import WEB_CAPABILITIES, WebOutbound from yuxi.channel.channels.web.translator import WebTranslator from yuxi.channel.domain.model.message.dispatch_result import SendResult from yuxi.channel.domain.model.message.unified_message import UnifiedMessage from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities from yuxi.channel.domain.model.shared.channel_type import ChannelType -from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor +from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor from yuxi.channel.domain.port.sse_push_port import SsePushPort from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort @@ -50,6 +51,10 @@ class WebAdapter: "max_connections": 1000, } + @classmethod + def from_config(cls, config: WebConfig) -> WebAdapter: + return cls(sse_push=None) + async def open(self) -> None: self._opened = True @@ -57,7 +62,7 @@ class WebAdapter: self._opened = False async def receive_message(self, raw: dict) -> UnifiedMessage: - return WebTranslator.translate_message(raw) + return WebTranslator.translate(raw) async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult: try: diff --git a/backend/package/yuxi/channel/channels/web/config.py b/backend/package/yuxi/channel/channels/web/config.py index 5fefe236..fa29d45b 100644 --- a/backend/package/yuxi/channel/channels/web/config.py +++ b/backend/package/yuxi/channel/channels/web/config.py @@ -1,8 +1,12 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field + +from yuxi.channel.channels.web.outbound import WEB_CAPABILITIES +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities @dataclass class WebConfig: max_connections: int = 1000 + capabilities: ChannelCapabilities = field(default_factory=lambda: WEB_CAPABILITIES) diff --git a/backend/package/yuxi/channel/channels/web/routes.py b/backend/package/yuxi/channel/channels/web/routes.py index 795b4b2e..08645fe4 100644 --- a/backend/package/yuxi/channel/channels/web/routes.py +++ b/backend/package/yuxi/channel/channels/web/routes.py @@ -7,7 +7,6 @@ from uuid import uuid4 from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse -from yuxi.channel.channels.web.translator import WebTranslator from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP logger = logging.getLogger(__name__) @@ -15,7 +14,7 @@ logger = logging.getLogger(__name__) router = APIRouter() -@router.post("/channel/web/message") +@router.post("/message") async def web_message( request: Request, body: bytes = Body(..., max_length=256 * 1024), @@ -23,26 +22,36 @@ async def web_message( from yuxi.channel.container import get_channel container = get_channel(request) - if not container: - raise HTTPException(status_code=503, detail="channel not initialized") adapter = container.adapters.get("web") if not adapter: raise HTTPException(status_code=503, detail="web adapter not available") + verifier = container.verifiers.get("web") + verify_result = None + if verifier and verifier.enabled: + verify_result = await verifier.verify(body, dict(request.headers)) + if not verify_result.passed: + raise HTTPException(status_code=401, detail=verify_result.reason) + try: raw = json.loads(body) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="invalid JSON") - message = WebTranslator.translate_message(raw) + try: + message = await adapter.receive_message(raw) + except Exception: + logger.exception("web message parse failed") + raise HTTPException(status_code=400, detail="message parse failed") trace_id = raw.get("trace_id", request.headers.get("x-trace-id", str(uuid4()))) - idempotency_key = request.headers.get("idempotency-key") + idempotency_key = request.headers.get("idempotency-key") or trace_id message.metadata["trace_id"] = trace_id - if idempotency_key: - message.metadata["idempotency_key"] = idempotency_key + message.metadata["idempotency_key"] = idempotency_key message.metadata["client_ip"] = request.client.host if request.client else "unknown" + if verify_result: + message.metadata["auth_method"] = verify_result.method result = await container.inbound_service.submit(message, channel_type="web", trace_id=trace_id) diff --git a/backend/package/yuxi/channel/channels/web/translator.py b/backend/package/yuxi/channel/channels/web/translator.py index 435a8cb0..16de7f6c 100644 --- a/backend/package/yuxi/channel/channels/web/translator.py +++ b/backend/package/yuxi/channel/channels/web/translator.py @@ -7,7 +7,7 @@ from yuxi.channel.domain.model.shared.channel_type import ChannelType class WebTranslator: @staticmethod - def translate_message(raw: dict) -> UnifiedMessage: + def translate(raw: dict) -> UnifiedMessage: return UnifiedMessage( message_id=raw.get("id", ""), channel_type=ChannelType.WEB, @@ -20,3 +20,7 @@ class WebTranslator: metadata=raw.get("metadata", {}), raw_payload=raw, ) + + @staticmethod + def translate_message(raw: dict) -> UnifiedMessage: + return WebTranslator.translate(raw) diff --git a/backend/package/yuxi/channel/channels/web/verifier.py b/backend/package/yuxi/channel/channels/web/verifier.py new file mode 100644 index 00000000..6ca05a85 --- /dev/null +++ b/backend/package/yuxi/channel/channels/web/verifier.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import hmac +import logging + +from yuxi.channel.domain.port.channel_request_verifier_port import VerifyResult + +logger = logging.getLogger(__name__) + + +class WebRequestVerifier: + def __init__(self, *, auth_token: str = "") -> None: + self._auth_token = auth_token + + @property + def channel_type(self) -> str: + return "web" + + @property + def enabled(self) -> bool: + return bool(self._auth_token) + + async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult: + if not self._auth_token: + return VerifyResult(passed=True, method="web_none", reason="no_auth_configured") + + auth_header = headers.get("authorization", "") + if auth_header.startswith("Bearer "): + token = auth_header[7:] + if hmac.compare_digest(token, self._auth_token): + return VerifyResult(passed=True, method="web_bearer") + return VerifyResult(passed=False, method="web_bearer", reason="invalid_bearer_token") + + return VerifyResult(passed=False, method="web_auth", reason="authentication_required") diff --git a/backend/package/yuxi/channel/container.py b/backend/package/yuxi/channel/container.py index 1c65ebd0..7b6c4c23 100644 --- a/backend/package/yuxi/channel/container.py +++ b/backend/package/yuxi/channel/container.py @@ -17,10 +17,15 @@ from yuxi.channel.application.service.delivery_service import DeliveryService from yuxi.channel.application.service.dispatch_service import DispatchService from yuxi.channel.application.service.inbound_service import InboundService from yuxi.channel.application.service.session_resolver import SessionResolver -from yuxi.channel.channels._registry import get_registered_channels +from yuxi.channel.channels._registry import get_channel_meta from yuxi.channel.domain.port.agent_port import AgentPort +from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort from yuxi.channel.domain.port.cache_port import CachePort +from yuxi.channel.application.service.plugin_registry_app_service import PluginRegistryAppService +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort +from yuxi.channel.domain.port.channel_request_verifier_port import ChannelRequestVerifierPort +from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort from yuxi.channel.domain.port.content_filter_port import ContentFilterPort from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort @@ -28,8 +33,6 @@ from yuxi.channel.domain.port.metrics_port import MetricsPort from yuxi.channel.domain.port.queue_port import QueuePort from yuxi.channel.domain.port.rate_limit_port import RateLimitPort from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort -from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort -from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort from yuxi.channel.domain.repository.message_log_repository import MessageLogRepositoryPort from yuxi.channel.domain.repository.message_repository import MessageRepositoryPort @@ -50,6 +53,7 @@ from yuxi.channel.infrastructure.messaging.redis_pubsub_publisher import RedisPu from yuxi.channel.infrastructure.messaging.redis_pubsub_subscriber import RedisPubSubSubscriber from yuxi.channel.infrastructure.messaging.redis_stream_queue import RedisStreamQueue from yuxi.channel.infrastructure.metrics.prometheus_metrics import PrometheusMetricsAdapter +from yuxi.channel.infrastructure.persistence.repository.pg_agent_config_lookup import PgAgentConfigLookup from yuxi.channel.infrastructure.persistence.repository.pg_binding_repository import PgBindingRepository from yuxi.channel.infrastructure.persistence.repository.pg_message_log_repository import PgMessageLogRepository from yuxi.channel.infrastructure.persistence.repository.pg_message_repository import PgMessageRepository @@ -142,6 +146,7 @@ class ChannelContainer: outbox_worker: OutboxRetryWorker session_factory: WorkerSessionFactory adapters: dict[str, ChannelAdapterPort] = field(default_factory=dict) + verifiers: dict[str, ChannelRequestVerifierPort] = field(default_factory=dict) sse_endpoint: SseEndpoint | None = None ws_manager: WsConnectionManager | None = None binding_repo: BindingRepositoryPort | None = None @@ -162,6 +167,25 @@ class ChannelContainer: startup_tracer: StartupTracer = field(default_factory=StartupTracer) redis: aioredis.Redis | None = None pubsub_subscriber: RedisPubSubSubscriber | None = None + _plugin_registry: PluginRegistry | None = field(default=None, repr=False, compare=False) + plugin_registry_impl: object | None = None + plugin_app_service: PluginRegistryAppService | None = None + + def get_adapters_snapshot(self) -> dict[str, ChannelAdapterPort]: + if self._plugin_registry: + return self._plugin_registry.list_adapters() + return dict(self.adapters) + + def get_adapter(self, channel_type: str) -> ChannelAdapterPort | None: + if self._plugin_registry: + return self._plugin_registry.get_adapter(channel_type) + return self.adapters.get(channel_type) + + def require(self, attr_name: str) -> object: + value = getattr(self, attr_name, None) + if value is None: + raise HTTPException(status_code=503, detail=f"{attr_name} not initialized") + return value async def shutdown(self) -> None: async def _safe(coro, label: str): @@ -190,7 +214,7 @@ class ChannelContainer: await _safe(self.ws_manager.stop_all(), "ws_connections") if self.sse_endpoint: await _safe(self.sse_endpoint.stop(), "sse_endpoint") - for name, adapter in self.adapters.items(): + for name, adapter in self.get_adapters_snapshot().items(): await _safe(adapter.close(), f"adapter:{name}") if self.redis: await _safe(self.redis.aclose(), "redis") @@ -228,6 +252,7 @@ class ChannelContainerFactory: mq_workers: int = 4, mq_max_concurrent: int = 20, default_agent_config_id: int = 1, + plugin_registry_impl: object | None = None, ) -> ChannelContainer: tracer = StartupTracer() @@ -252,14 +277,29 @@ class ChannelContainerFactory: tracer.end() tracer.begin("adapters") - adapters, sse_endpoint = await ChannelContainerFactory._build_adapters(config, infra) + adapters, sse_endpoint = await ChannelContainerFactory._build_adapters( + config, + infra, + plugin_registry_impl=plugin_registry_impl, + ) tracer.end() ChannelContainerFactory._inject_bot_ids(pipeline, adapters) + for warning in ChannelContainerFactory._validate_channel_consistency(adapters, config): + logger.warning(warning) + + tracer.begin("verifiers") + verifiers = ChannelContainerFactory._build_verifiers(config, adapters) + tracer.end() + tracer.begin("ws_connections") ws_manager = await ChannelContainerFactory._build_ws_connections( - adapters, pipeline, tracer, metrics=infra.metrics + adapters, + pipeline, + tracer, + metrics=infra.metrics, + registry=plugin_registry_impl.registry if plugin_registry_impl else None, ) tracer.end() @@ -274,6 +314,8 @@ class ChannelContainerFactory: default_agent_config_id=default_agent_config_id, mq_workers=mq_workers, mq_max_concurrent=mq_max_concurrent, + registry=plugin_registry_impl.registry if plugin_registry_impl else None, + auth_service=auth_service, ) await workers.worker_pool.start() await workers.outbox_worker.start() @@ -291,6 +333,7 @@ class ChannelContainerFactory: container = ChannelContainer( pipeline=pipeline, adapters=adapters, + verifiers=verifiers, inbound_service=inbound_service, dispatch_service=workers.dispatch_service, delivery_service=workers.dispatch_service.delivery_service, @@ -324,6 +367,14 @@ class ChannelContainerFactory: pubsub_subscriber=pubsub_subscriber, ) + if plugin_registry_impl is not None: + container._plugin_registry = plugin_registry_impl.registry + container.plugin_registry_impl = plugin_registry_impl + container.plugin_app_service = PluginRegistryAppService( + registry_impl=plugin_registry_impl, + event_publisher=infra.event_publisher, + ) + if infra.config_reload: async def _on_config_change(config: dict): @@ -406,54 +457,100 @@ class ChannelContainerFactory: async def _build_adapters( config: ChannelConfig, infra: _InfraBundle, + *, + plugin_registry_impl: object | None = None, ) -> tuple[dict[str, ChannelAdapterPort], SseEndpoint]: import yuxi.channel.channels # noqa: F401 + from yuxi.channel.channels._registry import get_registered_channels + sse_endpoint = SseEndpoint(metrics=infra.metrics) await sse_endpoint.start() + infra_bundle: dict[str, object] = { + "sse_push": sse_endpoint, + "hook_mappings": config.get_channel_config("hooks").get("mappings", []), + } + adapters: dict[str, ChannelAdapterPort] = {} - for name, cls in get_registered_channels().items(): - default_config = cls.get_default_config() - override = config.get_channel_config(name) - merged = {**default_config, **override} - if name == "web": - merged["sse_push"] = sse_endpoint + if plugin_registry_impl is not None: + from yuxi.channel.domain.service.plugin_registry_domain_service import PluginRegistryDomainService - if name == "hooks": - hook_mappings = config.hook_mappings - if hook_mappings: - merged["mappings"] = hook_mappings + registry: PluginRegistry = plugin_registry_impl.registry - env_overrides = ChannelContainerFactory._apply_env_overrides(name, merged) - merged.update(env_overrides) + for record in registry.list_records(): + if not record.source.enabled: + continue - adapter = cls(**merged) - await adapter.open() - adapters[name] = adapter + default_config = record.get_default_config() + override = config.get_channel_config(record.channel_type) + merged = {**default_config, **override} + + meta = get_channel_meta(record.channel_type) + for dep in meta.get("infra_dependencies", []): + if dep in infra_bundle and dep not in merged: + merged[dep] = infra_bundle[dep] + + env_mapping = meta.get("env_mapping", {}) + if env_mapping: + env_overrides = ChannelContainerFactory._apply_env_overrides_generic(env_mapping) + merged.update(env_overrides) + + record.set_config(merged) + PluginRegistryDomainService.transition_to_configured(record) + + adapter = await registry.activate(record.plugin_id, **merged) + assert isinstance(adapter, ChannelAdapterPort), ( + f"{type(adapter).__name__} does not implement ChannelAdapterPort" + ) + + adapters = registry.list_adapters() + else: + for name, cls in get_registered_channels().items(): + meta = get_channel_meta(name) + default_config = cls.get_default_config() + override = config.get_channel_config(name) + merged = {**default_config, **override} + + for dep in meta.get("infra_dependencies", []): + if dep in infra_bundle and dep not in merged: + merged[dep] = infra_bundle[dep] + + env_mapping = meta.get("env_mapping", {}) + if env_mapping: + env_overrides = ChannelContainerFactory._apply_env_overrides_generic(env_mapping) + merged.update(env_overrides) + + adapter = cls(**merged) + assert isinstance(adapter, ChannelAdapterPort), f"{cls.__name__} does not implement ChannelAdapterPort" + await adapter.open() + adapters[name] = adapter return adapters, sse_endpoint @staticmethod - def _apply_env_overrides(channel_name: str, config: dict) -> dict: + def _apply_env_overrides_generic(env_mapping: dict[str, str]) -> dict: overrides: dict = {} - if channel_name == "feishu": - app_id = os.getenv("FEISHU_APP_ID", "") - app_secret = os.getenv("FEISHU_APP_SECRET", "") - if app_id: - overrides["app_id"] = app_id - if app_secret: - overrides["app_secret"] = app_secret - elif channel_name == "dingtalk": - client_id = os.getenv("DINGTALK_CLIENT_ID", "") - client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "") - if client_id: - overrides["client_id"] = client_id - if client_secret: - overrides["client_secret"] = client_secret + for env_var, config_key in env_mapping.items(): + value = os.getenv(env_var, "") + if value: + overrides[config_key] = value return overrides + @staticmethod + def _validate_channel_consistency(adapters: dict, config: ChannelConfig) -> list[str]: + from yuxi.channel.domain.model.shared.channel_type import ChannelType + + warnings_list: list[str] = [] + valid_types = {t.value for t in ChannelType} + for name in adapters: + if name not in valid_types: + warnings_list.append(f"adapter '{name}' not in ChannelType enum") + if not config.get_channel_config(name): + warnings_list.append(f"no config section for channel '{name}' in YAML") + return warnings_list + @staticmethod def _inject_bot_ids(pipeline: Pipeline, adapters: dict[str, ChannelAdapterPort]) -> None: from yuxi.channel.application.pipeline.middlewares.mention_gate_middleware import MentionGateMiddleware @@ -466,15 +563,39 @@ class ChannelContainerFactory: middleware.set_bot_id(bot_id) break + @staticmethod + def _build_verifiers( + config: ChannelConfig, + adapters: dict[str, ChannelAdapterPort], + ) -> dict[str, ChannelRequestVerifierPort]: + from yuxi.channel.channels._registry import get_channel_meta + + verifiers: dict[str, ChannelRequestVerifierPort] = {} + for name in adapters: + meta = get_channel_meta(name) + verifier_factory = meta.get("verifier_factory") + if verifier_factory: + channel_config = config.get_channel_config(name) + verifiers[name] = verifier_factory(channel_config) + + for name, verifier in verifiers.items(): + if not verifier.enabled: + logger.warning("channel '%s' is running without request verification", name) + + return verifiers + @staticmethod async def _build_ws_connections( adapters: dict[str, ChannelAdapterPort], pipeline: Pipeline, tracer: StartupTracer, metrics: MetricsPort | None = None, + *, + registry: PluginRegistry | None = None, ) -> WsConnectionManager: - ws_manager = WsConnectionManager(metrics=metrics) - ws_manager.set_adapters(adapters) + ws_manager = WsConnectionManager(metrics=metrics, registry=registry) + if not registry: + ws_manager.set_adapters(adapters) for name, adapter in adapters.items(): ws = adapter.ws_connection @@ -499,6 +620,8 @@ class ChannelContainerFactory: default_agent_config_id: int = 1, mq_workers: int = 4, mq_max_concurrent: int = 20, + registry: PluginRegistry | None = None, + auth_service: AuthService | None = None, ) -> _WorkerBundle: session_factory = WorkerSessionFactory(pg_manager) db_session_factory = pg_manager.get_async_session_context @@ -509,7 +632,7 @@ class ChannelContainerFactory: infra.cache_port, agent_id_resolver=lambda aid: _resolve_agent_id(db_session_factory, aid), ) - outbox_repo = PgOutboxRepository(db_session_factory, redis) + outbox_repo = PgOutboxRepository(db_session_factory, infra.cache_port) message_repo = PgMessageRepository(db_session_factory) message_log_repo = PgMessageLogRepository(db_session_factory) @@ -523,6 +646,7 @@ class ChannelContainerFactory: delivery_service = DeliveryService( adapters=adapters, + registry=registry, message_repo=message_repo, outbox_repo=outbox_repo, event_publisher=infra.event_publisher, @@ -547,14 +671,15 @@ class ChannelContainerFactory: config=WorkerPoolConfig(num_workers=mq_workers, max_concurrent=mq_max_concurrent), ) - outbox_worker = OutboxRetryWorker(outbox_repo, adapters, redis, metrics=infra.metrics) + outbox_worker = OutboxRetryWorker(outbox_repo, adapters, redis, metrics=infra.metrics, registry=registry) - binding_service = BindingService(binding_repo) + binding_service = BindingService(binding_repo, PgAgentConfigLookup(db_session_factory)) config_service = ConfigService( config_data=config.raw_data, config_reload=infra.config_reload, pipeline=pipeline, channel_config=config, + auth_service=auth_service, ) return _WorkerBundle( diff --git a/backend/package/yuxi/channel/domain/event/plugin/__init__.py b/backend/package/yuxi/channel/domain/event/plugin/__init__.py new file mode 100644 index 00000000..3f558424 --- /dev/null +++ b/backend/package/yuxi/channel/domain/event/plugin/__init__.py @@ -0,0 +1,17 @@ +from yuxi.channel.domain.event.plugin.plugin_activated_event import ( + PluginActivatedEvent as PluginActivatedEvent, +) +from yuxi.channel.domain.event.plugin.plugin_deactivated_event import ( + PluginDeactivatedEvent as PluginDeactivatedEvent, +) +from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent as PluginReloadedEvent +from yuxi.channel.domain.event.plugin.plugin_registered_event import ( + PluginRegisteredEvent as PluginRegisteredEvent, +) + +__all__ = [ + "PluginRegisteredEvent", + "PluginActivatedEvent", + "PluginDeactivatedEvent", + "PluginReloadedEvent", +] diff --git a/backend/package/yuxi/channel/domain/event/plugin/plugin_activated_event.py b/backend/package/yuxi/channel/domain/event/plugin/plugin_activated_event.py new file mode 100644 index 00000000..862e94fb --- /dev/null +++ b/backend/package/yuxi/channel/domain/event/plugin/plugin_activated_event.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PluginActivatedEvent: + plugin_id: str + channel_type: str diff --git a/backend/package/yuxi/channel/domain/event/plugin/plugin_deactivated_event.py b/backend/package/yuxi/channel/domain/event/plugin/plugin_deactivated_event.py new file mode 100644 index 00000000..d8308633 --- /dev/null +++ b/backend/package/yuxi/channel/domain/event/plugin/plugin_deactivated_event.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PluginDeactivatedEvent: + plugin_id: str + channel_type: str diff --git a/backend/package/yuxi/channel/domain/event/plugin/plugin_registered_event.py b/backend/package/yuxi/channel/domain/event/plugin/plugin_registered_event.py new file mode 100644 index 00000000..8ce9f131 --- /dev/null +++ b/backend/package/yuxi/channel/domain/event/plugin/plugin_registered_event.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PluginRegisteredEvent: + plugin_id: str + channel_type: str diff --git a/backend/package/yuxi/channel/domain/event/plugin/plugin_reload_event.py b/backend/package/yuxi/channel/domain/event/plugin/plugin_reload_event.py new file mode 100644 index 00000000..9a07ea10 --- /dev/null +++ b/backend/package/yuxi/channel/domain/event/plugin/plugin_reload_event.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PluginReloadedEvent: + plugin_id: str + old_version: str + new_version: str + channel_type: str diff --git a/backend/package/yuxi/channel/domain/exception/__init__.py b/backend/package/yuxi/channel/domain/exception/__init__.py index 4efdb10f..e6cffda5 100644 --- a/backend/package/yuxi/channel/domain/exception/__init__.py +++ b/backend/package/yuxi/channel/domain/exception/__init__.py @@ -1,5 +1,49 @@ -from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP -from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError -from yuxi.channel.domain.exception.channel_error import ChannelError -from yuxi.channel.domain.exception.recoverable_error import RecoverableError -from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError +from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP as ABORT_CODE_TO_HTTP +from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError as AgentCrashError +from yuxi.channel.domain.exception.channel_error import ChannelError as ChannelError +from yuxi.channel.domain.exception.plugin_exception import ( + ActiveSessionExistsException as ActiveSessionExistsException, + ConfigNotFoundException as ConfigNotFoundException, + ConfigValidationException as ConfigValidationException, + DuplicatePluginException as DuplicatePluginException, + InvalidManifestException as InvalidManifestException, + InvalidPluginStateException as InvalidPluginStateException, + PluginAdapterNotFoundException as PluginAdapterNotFoundException, + PluginAlreadyActivatedException as PluginAlreadyActivatedException, + PluginAlreadyRegisteredException as PluginAlreadyRegisteredException, + PluginException as PluginException, + PluginNotFoundException as PluginNotFoundException, + PluginNotConfiguredException as PluginNotConfiguredException, + PluginRegistrationException as PluginRegistrationException, + PluginReloadException as PluginReloadException, + RegistryLockedException as RegistryLockedException, + SnapshotException as SnapshotException, +) +from yuxi.channel.domain.exception.recoverable_error import RecoverableError as RecoverableError +from yuxi.channel.domain.exception.session_exception import SessionNotFoundError as SessionNotFoundError +from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError as UnrecoverableError + +__all__ = [ + "ABORT_CODE_TO_HTTP", + "ActiveSessionExistsException", + "AgentCrashError", + "ChannelError", + "ConfigNotFoundException", + "ConfigValidationException", + "DuplicatePluginException", + "InvalidManifestException", + "InvalidPluginStateException", + "PluginAdapterNotFoundException", + "PluginAlreadyActivatedException", + "PluginAlreadyRegisteredException", + "PluginException", + "PluginNotFoundException", + "PluginNotConfiguredException", + "PluginRegistrationException", + "PluginReloadException", + "RegistryLockedException", + "RecoverableError", + "SessionNotFoundError", + "SnapshotException", + "UnrecoverableError", +] diff --git a/backend/package/yuxi/channel/domain/exception/agent_config_not_found.py b/backend/package/yuxi/channel/domain/exception/agent_config_not_found.py new file mode 100644 index 00000000..4d4bf5d8 --- /dev/null +++ b/backend/package/yuxi/channel/domain/exception/agent_config_not_found.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +class AgentConfigNotFoundException(Exception): + def __init__(self, agent_config_id: int): + self.agent_config_id = agent_config_id + super().__init__(f"agent config not found: {agent_config_id}") diff --git a/backend/package/yuxi/channel/domain/exception/duplicate_binding.py b/backend/package/yuxi/channel/domain/exception/duplicate_binding.py new file mode 100644 index 00000000..a383883c --- /dev/null +++ b/backend/package/yuxi/channel/domain/exception/duplicate_binding.py @@ -0,0 +1,9 @@ +from __future__ import annotations + + +class DuplicateBindingException(Exception): + def __init__(self, channel_type: str, account_id: str, group_id: str): + self.channel_type = channel_type + self.account_id = account_id + self.group_id = group_id + super().__init__(f"binding already exists: {channel_type}/{account_id}/{group_id}") diff --git a/backend/package/yuxi/channel/domain/exception/plugin_exception.py b/backend/package/yuxi/channel/domain/exception/plugin_exception.py new file mode 100644 index 00000000..676fa332 --- /dev/null +++ b/backend/package/yuxi/channel/domain/exception/plugin_exception.py @@ -0,0 +1,92 @@ +from __future__ import annotations + + +class PluginException(Exception): + def __init__(self, message: str = "", plugin_id: str = ""): + self.plugin_id = plugin_id + super().__init__(message) + + +class DuplicatePluginException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"plugin already exists: {plugin_id}", plugin_id) + + +class PluginNotFoundException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"plugin not found: {plugin_id}", plugin_id) + + +class PluginAlreadyRegisteredException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"plugin already registered: {plugin_id}", plugin_id) + + +class PluginAlreadyActivatedException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"plugin already activated: {plugin_id}", plugin_id) + + +class PluginNotConfiguredException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"plugin not configured: {plugin_id}", plugin_id) + + +class PluginAdapterNotFoundException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"adapter class not found for plugin: {plugin_id}", plugin_id) + + +class PluginRegistrationException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"plugin registration failed: {plugin_id}", plugin_id) + + +class PluginReloadException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"plugin reload failed: {plugin_id}", plugin_id) + + +class InvalidManifestException(PluginException): + def __init__(self, message: str): + super().__init__(f"invalid manifest: {message}") + + +class RegistryLockedException(PluginException): + def __init__(self): + super().__init__("registry is locked") + + +class SnapshotException(PluginException): + def __init__(self, message: str = "snapshot operation failed"): + super().__init__(message) + + +class ConfigValidationException(PluginException): + def __init__(self, plugin_id: str, message: str): + super().__init__(f"config validation failed for {plugin_id}: {message}", plugin_id) + + +class ConfigNotFoundException(PluginException): + def __init__(self, plugin_id: str): + super().__init__(f"config not found for plugin: {plugin_id}", plugin_id) + + +class InvalidPluginStateException(PluginException): + def __init__(self, plugin_id: str, current_status: str, target_action: str): + super().__init__( + f"cannot {target_action} plugin {plugin_id} in {current_status} state", + plugin_id, + ) + self.current_status = current_status + self.target_action = target_action + + +class ActiveSessionExistsException(PluginException): + def __init__(self, plugin_id: str, channel_type: str, active_count: int): + super().__init__( + f"channel {channel_type} has {active_count} active sessions, graceful deactivate required", + plugin_id, + ) + self.channel_type = channel_type + self.active_count = active_count diff --git a/backend/package/yuxi/channel/domain/exception/session_exception.py b/backend/package/yuxi/channel/domain/exception/session_exception.py new file mode 100644 index 00000000..2e2b2671 --- /dev/null +++ b/backend/package/yuxi/channel/domain/exception/session_exception.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from yuxi.channel.domain.exception.channel_error import ChannelError + + +class SessionNotFoundError(ChannelError): + def __init__(self, thread_id: str): + self.thread_id = thread_id + super().__init__(f"Session not found for thread_id: {thread_id}") diff --git a/backend/package/yuxi/channel/domain/model/binding/channel_binding.py b/backend/package/yuxi/channel/domain/model/binding/channel_binding.py index bce9f16c..9d9d9ec4 100644 --- a/backend/package/yuxi/channel/domain/model/binding/channel_binding.py +++ b/backend/package/yuxi/channel/domain/model/binding/channel_binding.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime @dataclass @@ -14,8 +15,8 @@ class ChannelBinding: is_enabled: bool = True created_by: str | None = None updated_by: str | None = None - created_at: str = "" - updated_at: str = "" + created_at: datetime | None = None + updated_at: datetime | None = None def resolve_session_key_strategy(self) -> str: if self.session_key_strategy != "auto": diff --git a/backend/package/yuxi/channel/domain/model/outbox/__init__.py b/backend/package/yuxi/channel/domain/model/outbox/__init__.py index 08b74614..14c407b4 100644 --- a/backend/package/yuxi/channel/domain/model/outbox/__init__.py +++ b/backend/package/yuxi/channel/domain/model/outbox/__init__.py @@ -1 +1,2 @@ -from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry +from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry as OutboxEntry +from yuxi.channel.domain.model.outbox.outbox_status import OutboxStatus as OutboxStatus diff --git a/backend/package/yuxi/channel/domain/model/outbox/outbox_entry.py b/backend/package/yuxi/channel/domain/model/outbox/outbox_entry.py index 81293698..b06a4be6 100644 --- a/backend/package/yuxi/channel/domain/model/outbox/outbox_entry.py +++ b/backend/package/yuxi/channel/domain/model/outbox/outbox_entry.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime @dataclass @@ -13,7 +14,7 @@ class OutboxEntry: status: str retry_count: int max_retries: int - next_retry_at: str | None + next_retry_at: datetime | None last_error: str | None trace_id: str | None extra_metadata: dict | None diff --git a/backend/package/yuxi/channel/domain/model/outbox/outbox_status.py b/backend/package/yuxi/channel/domain/model/outbox/outbox_status.py new file mode 100644 index 00000000..b9707f5b --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/outbox/outbox_status.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from enum import StrEnum + + +class OutboxStatus(StrEnum): + PENDING = "pending" + PROCESSING = "processing" + RETRYING = "retrying" + SENT = "sent" + FAILED = "failed" + DEAD = "dead" diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/__init__.py b/backend/package/yuxi/channel/domain/model/plugin_registry/__init__.py new file mode 100644 index 00000000..6ca20264 --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/__init__.py @@ -0,0 +1,23 @@ +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest as PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord as PluginRecord +from yuxi.channel.domain.model.plugin_registry.plugin_registration_mode import ( + PluginRegistrationMode as PluginRegistrationMode, +) +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry as PluginRegistry +from yuxi.channel.domain.model.plugin_registry.plugin_source import ( + PluginOrigin as PluginOrigin, +) +from yuxi.channel.domain.model.plugin_registry.plugin_source import ( + PluginSource as PluginSource, +) +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus as PluginStatus + +__all__ = [ + "PluginManifest", + "PluginRecord", + "PluginRegistrationMode", + "PluginRegistry", + "PluginOrigin", + "PluginSource", + "PluginStatus", +] diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_manifest.py b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_manifest.py new file mode 100644 index 00000000..05a5d2a8 --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_manifest.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + + +@dataclass(frozen=True) +class PluginManifest: + id: str + name: str + version: str + channel_type: str + capabilities: ChannelCapabilities + config_schema: dict | None = None + entry: str = "" + dependencies: list[str] = field(default_factory=list) + + def __post_init__(self): + if not self.id or not self.channel_type: + raise InvalidManifestException("plugin id and channel_type are required") + if not self.version: + object.__setattr__(self, "version", "0.0.0") diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_persistent_record.py b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_persistent_record.py new file mode 100644 index 00000000..05088544 --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_persistent_record.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime + + +@dataclass +class PluginPersistentRecord: + id: int | None = None + plugin_id: str = "" + name: str = "" + version: str = "0.0.0" + channel_type: str = "" + origin: str = "" + status: str = "discovered" + root_dir: str | None = None + entry_file: str | None = None + manifest: dict = field(default_factory=dict) + capabilities: dict = field(default_factory=dict) + config_schema: dict | None = None + config: dict = field(default_factory=dict) + error: str | None = None + is_enabled: bool = True + registered_at: datetime | None = None + activated_at: datetime | None = None + created_by: str | None = None + updated_by: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +@dataclass +class PluginConfigHistory: + id: int | None = None + plugin_id: str = "" + config: dict = field(default_factory=dict) + changed_by: str | None = None + change_reason: str | None = None + created_at: datetime | None = None + + +@dataclass +class PluginAuditLog: + id: int | None = None + plugin_id: str = "" + action: str = "" + old_status: str | None = None + new_status: str | None = None + old_version: str | None = None + new_version: str | None = None + detail: dict | None = None + error_message: str | None = None + created_by: str | None = None + created_at: datetime | None = None diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_record.py b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_record.py new file mode 100644 index 00000000..6b7a31e6 --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_record.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from yuxi.channel.domain.exception.plugin_exception import ( + PluginAdapterNotFoundException, + PluginAlreadyRegisteredException, +) +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginSource +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort + + +@dataclass +class PluginRecordId: + value: str = field(default_factory=lambda: uuid.uuid4().hex) + + @staticmethod + def generate() -> PluginRecordId: + return PluginRecordId() + + +@dataclass +class PluginRecord: + record_id: PluginRecordId + plugin_id: str + name: str + version: str + channel_type: str + source: PluginSource + manifest: PluginManifest + capabilities: ChannelCapabilities + config_schema: dict | None = None + status: PluginStatus = PluginStatus.DISCOVERED + adapter_class: type | None = None + config: dict = field(default_factory=dict) + error: str | None = None + registered_at: datetime | None = None + + @property + def is_configured(self) -> bool: + if not self.config_schema: + return True + required = self.config_schema.get("required", []) + return all(key in self.config for key in required) + + def set_config(self, config: dict) -> None: + self.config = config + + def set_adapter_class(self, adapter_cls: type) -> None: + self.adapter_class = adapter_cls + + def get_default_config(self) -> dict: + if self.adapter_class and hasattr(self.adapter_class, "get_default_config"): + return self.adapter_class.get_default_config() + return {} + + def build_adapter(self, **config) -> ChannelAdapterPort: + if not self.adapter_class: + raise PluginAdapterNotFoundException(self.plugin_id) + merged = {**self.config, **config} + return self.adapter_class(**merged) + + def register(self, api: object) -> None: + if self.status != PluginStatus.DISCOVERED: + raise PluginAlreadyRegisteredException(self.plugin_id) + self.status = PluginStatus.REGISTERED + self.registered_at = datetime.now(UTC) + + def mark_error(self, error: str) -> None: + self.error = error + self.status = PluginStatus.ERROR + + def to_snapshot(self) -> dict: + return { + "plugin_id": self.plugin_id, + "name": self.name, + "version": self.version, + "channel_type": self.channel_type, + "status": self.status.value, + "source": self.source.origin.value, + "is_configured": self.is_configured, + "capabilities": { + "media": self.capabilities.media, + "group": self.capabilities.group, + "dm": self.capabilities.dm, + "streaming": self.capabilities.streaming, + "typing": self.capabilities.typing, + "reaction": self.capabilities.reaction, + "thread": self.capabilities.thread, + "max_text_length": self.capabilities.max_text_length, + }, + } diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registration_mode.py b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registration_mode.py new file mode 100644 index 00000000..22842a73 --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registration_mode.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from enum import StrEnum + + +class PluginRegistrationMode(StrEnum): + FULL = "full" + DISCOVERY = "discovery" + SETUP_ONLY = "setup_only" + CLI_METADATA = "cli_metadata" diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registry.py b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registry.py new file mode 100644 index 00000000..e253aa5a --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registry.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import asyncio +import copy +import uuid +from dataclasses import dataclass + +from yuxi.channel.domain.exception.plugin_exception import ( + DuplicatePluginException, + PluginAlreadyActivatedException, + PluginNotConfiguredException, + PluginNotFoundException, +) +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort + + +@dataclass +class RegistrySnapshot: + plugins: dict[str, PluginRecord] + adapters: dict[str, ChannelAdapterPort] + + +@dataclass +class PluginSpec: + origin: str | None = None + status: PluginStatus | None = None + + def is_satisfied_by(self, record: PluginRecord) -> bool: + if self.origin and record.source.origin.value != self.origin: + return False + if self.status and record.status != self.status: + return False + return True + + +class PluginRegistry: + def __init__(self) -> None: + self._registry_id: str = uuid.uuid4().hex + self._plugins: dict[str, PluginRecord] = {} + self._adapters: dict[str, ChannelAdapterPort] = {} + self._lock = asyncio.Lock() + self._plugin_locks: dict[str, asyncio.Lock] = {} + + @property + def registry_id(self) -> str: + return self._registry_id + + def _get_plugin_lock(self, plugin_id: str) -> asyncio.Lock: + lock = self._plugin_locks.get(plugin_id) + if lock is None: + lock = asyncio.Lock() + self._plugin_locks[plugin_id] = lock + return lock + + async def register(self, record: PluginRecord, api: object | None = None) -> None: + async with self._lock: + if record.plugin_id in self._plugins: + raise DuplicatePluginException(record.plugin_id) + + snapshot = self._snapshot() + try: + if api is not None: + record.register(api) + else: + record.status = PluginStatus.REGISTERED + self._plugins[record.plugin_id] = record + except Exception: + self._restore(snapshot) + raise + + def register_builtin(self, record: PluginRecord) -> None: + if record.plugin_id in self._plugins: + return + record.status = PluginStatus.REGISTERED + self._plugins[record.plugin_id] = record + + async def activate(self, plugin_id: str, **config) -> ChannelAdapterPort: + async with self._lock: + async with self._get_plugin_lock(plugin_id): + record = self._get_plugin(plugin_id) + if not record.is_configured: + raise PluginNotConfiguredException(plugin_id) + + if record.channel_type in self._adapters: + raise PluginAlreadyActivatedException(plugin_id) + + adapter = record.build_adapter(**config) + await adapter.open() + self._adapters[record.channel_type] = adapter + record.status = PluginStatus.ACTIVE + return adapter + + async def deactivate(self, plugin_id: str) -> None: + async with self._lock: + async with self._get_plugin_lock(plugin_id): + record = self._get_plugin(plugin_id) + adapter = self._adapters.pop(record.channel_type, None) + if adapter: + try: + await adapter.close() + except Exception: + pass + record.status = PluginStatus.DISABLED + + async def reload(self, plugin_id: str, **config) -> ChannelAdapterPort: + async with self._lock: + async with self._get_plugin_lock(plugin_id): + record = self._get_plugin(plugin_id) + + snapshot = self._deep_snapshot() + + old_adapter = self._adapters.pop(record.channel_type, None) + if old_adapter: + try: + await old_adapter.close() + except Exception: + pass + record.status = PluginStatus.CONFIGURED + + try: + new_adapter = record.build_adapter(**config) + await new_adapter.open() + self._adapters[record.channel_type] = new_adapter + record.status = PluginStatus.ACTIVE + return new_adapter + except Exception: + self._restore(snapshot) + raise + + def get_adapter(self, channel_type: str) -> ChannelAdapterPort | None: + return self._adapters.get(channel_type) + + def get_record(self, plugin_id: str) -> PluginRecord | None: + return self._plugins.get(plugin_id) + + def list_records(self, spec: PluginSpec | None = None) -> list[PluginRecord]: + records = list(self._plugins.values()) + if spec: + records = [r for r in records if spec.is_satisfied_by(r)] + return records + + def list_adapters(self) -> dict[str, ChannelAdapterPort]: + return dict(self._adapters) + + def _get_plugin(self, plugin_id: str) -> PluginRecord: + record = self._plugins.get(plugin_id) + if not record: + raise PluginNotFoundException(plugin_id) + return record + + def snapshot(self) -> RegistrySnapshot: + return self._snapshot() + + def restore(self, snapshot: RegistrySnapshot) -> None: + self._restore(snapshot) + + def _snapshot(self) -> RegistrySnapshot: + return RegistrySnapshot( + plugins=dict(self._plugins), + adapters=dict(self._adapters), + ) + + def _deep_snapshot(self) -> RegistrySnapshot: + return RegistrySnapshot( + plugins={k: copy.copy(v) for k, v in self._plugins.items()}, + adapters=dict(self._adapters), + ) + + def _restore(self, snapshot: RegistrySnapshot) -> None: + self._plugins = snapshot.plugins + self._adapters = snapshot.adapters diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registry_factory.py b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registry_factory.py new file mode 100644 index 00000000..32ace9ed --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_registry_factory.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + + +class PluginRegistryFactory: + @staticmethod + def create_from_manifest( + manifest: PluginManifest, + source: PluginSource, + adapter_class: type | None = None, + ) -> PluginRecord: + record = PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id=manifest.id, + name=manifest.name, + version=manifest.version, + channel_type=manifest.channel_type, + source=source, + manifest=manifest, + capabilities=manifest.capabilities, + config_schema=manifest.config_schema, + ) + if adapter_class is not None: + record.set_adapter_class(adapter_class) + return record + + @staticmethod + def create_builtin( + plugin_id: str, + name: str, + channel_type: str, + adapter_class: type, + capabilities: ChannelCapabilities | None = None, + ) -> PluginRecord: + caps = capabilities or ChannelCapabilities() + + manifest = PluginManifest( + id=plugin_id, + name=name, + version="1.0.0", + channel_type=channel_type, + capabilities=caps, + entry="", + ) + + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir=f"channels/{channel_type}", + entry_file=f"channels/{channel_type}/adapter.py", + ) + + record = PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id=plugin_id, + name=name, + version="1.0.0", + channel_type=channel_type, + source=source, + manifest=manifest, + capabilities=caps, + ) + record.set_adapter_class(adapter_class) + return record diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_source.py b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_source.py new file mode 100644 index 00000000..46bd61e7 --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_source.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class PluginOrigin(StrEnum): + BUNDLED = "bundled" + WORKSPACE = "workspace" + INSTALLED = "installed" + + +@dataclass(frozen=True) +class PluginSource: + origin: PluginOrigin + root_dir: str + entry_file: str + enabled: bool = True diff --git a/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_status.py b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_status.py new file mode 100644 index 00000000..9208f74f --- /dev/null +++ b/backend/package/yuxi/channel/domain/model/plugin_registry/plugin_status.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from enum import StrEnum + + +class PluginStatus(StrEnum): + DISCOVERED = "discovered" + REGISTERED = "registered" + CONFIGURED = "configured" + ACTIVE = "active" + ERROR = "error" + DISABLED = "disabled" diff --git a/backend/package/yuxi/channel/domain/port/__init__.py b/backend/package/yuxi/channel/domain/port/__init__.py index 6cda5250..bed6684a 100644 --- a/backend/package/yuxi/channel/domain/port/__init__.py +++ b/backend/package/yuxi/channel/domain/port/__init__.py @@ -1,16 +1,26 @@ -from yuxi.channel.domain.port.agent_port import AgentPort -from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort -from yuxi.channel.domain.port.cache_port import CachePort -from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort -from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor -from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort -from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort -from yuxi.channel.domain.port.content_filter_port import ContentFilterPort, FilterResult -from yuxi.channel.domain.port.event_publisher_port import DomainEvent, EventPublisherPort -from yuxi.channel.domain.port.keyword_matcher_port import KeywordMatcherPort -from yuxi.channel.domain.port.metrics_port import MetricsPort -from yuxi.channel.domain.port.queue_port import QueuePort -from yuxi.channel.domain.port.rate_limit_port import RateLimitPort -from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort -from yuxi.channel.domain.port.sse_push_port import SsePushPort -from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort +from yuxi.channel.domain.port.agent_port import AgentPort as AgentPort +from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort as BotLoopGuardPort +from yuxi.channel.domain.port.cache_port import CachePort as CachePort +from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort as ChannelAdapterPort +from yuxi.channel.domain.port.channel_request_verifier_port import ( + ChannelRequestVerifierPort as ChannelRequestVerifierPort, + VerifyResult as VerifyResult, +) +from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor as ChannelRouteContributor +from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort as CircuitBreakerPort +from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort as ConfigReloadPort +from yuxi.channel.domain.port.content_filter_port import ( + ContentFilterPort as ContentFilterPort, + FilterResult as FilterResult, +) +from yuxi.channel.domain.port.event_publisher_port import ( + DomainEvent as DomainEvent, + EventPublisherPort as EventPublisherPort, +) +from yuxi.channel.domain.port.keyword_matcher_port import KeywordMatcherPort as KeywordMatcherPort +from yuxi.channel.domain.port.metrics_port import MetricsPort as MetricsPort +from yuxi.channel.domain.port.queue_port import QueuePort as QueuePort +from yuxi.channel.domain.port.rate_limit_port import RateLimitPort as RateLimitPort +from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort as SignatureVerifyPort +from yuxi.channel.domain.port.sse_push_port import SsePushPort as SsePushPort +from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort as WsConnectionPort diff --git a/backend/package/yuxi/channel/domain/port/agent_config_lookup_port.py b/backend/package/yuxi/channel/domain/port/agent_config_lookup_port.py new file mode 100644 index 00000000..423b5f3a --- /dev/null +++ b/backend/package/yuxi/channel/domain/port/agent_config_lookup_port.py @@ -0,0 +1,8 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class AgentConfigLookupPort(Protocol): + async def exists(self, agent_config_id: int) -> bool: ... diff --git a/backend/package/yuxi/channel/domain/port/cache_port.py b/backend/package/yuxi/channel/domain/port/cache_port.py index a36011c7..db2e8c9e 100644 --- a/backend/package/yuxi/channel/domain/port/cache_port.py +++ b/backend/package/yuxi/channel/domain/port/cache_port.py @@ -18,3 +18,5 @@ class CachePort(Protocol): async def ttl(self, key: str) -> int: ... async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple: ... + + async def publish(self, channel: str, message: str) -> None: ... diff --git a/backend/package/yuxi/channel/domain/port/channel_request_verifier_port.py b/backend/package/yuxi/channel/domain/port/channel_request_verifier_port.py new file mode 100644 index 00000000..41533233 --- /dev/null +++ b/backend/package/yuxi/channel/domain/port/channel_request_verifier_port.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +@dataclass(frozen=True) +class VerifyResult: + passed: bool + method: str = "" + reason: str = "" + + +@runtime_checkable +class ChannelRequestVerifierPort(Protocol): + @property + def channel_type(self) -> str: ... + + @property + def enabled(self) -> bool: ... + + async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult: ... diff --git a/backend/package/yuxi/channel/domain/port/metrics_port.py b/backend/package/yuxi/channel/domain/port/metrics_port.py index 46488812..2c8c9b0b 100644 --- a/backend/package/yuxi/channel/domain/port/metrics_port.py +++ b/backend/package/yuxi/channel/domain/port/metrics_port.py @@ -40,3 +40,5 @@ class MetricsPort(Protocol): async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None: ... async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None: ... + + async def record_verification_failed(self, channel_type: str, reason: str) -> None: ... diff --git a/backend/package/yuxi/channel/domain/port/plugin_loader_port.py b/backend/package/yuxi/channel/domain/port/plugin_loader_port.py new file mode 100644 index 00000000..fb7f71fd --- /dev/null +++ b/backend/package/yuxi/channel/domain/port/plugin_loader_port.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PluginLoaderPort(Protocol): + async def load(self, entry_file: str) -> object: ... + + def extract_adapter_class(self, module: object) -> type | None: ... diff --git a/backend/package/yuxi/channel/domain/port/plugin_registration_api_port.py b/backend/package/yuxi/channel/domain/port/plugin_registration_api_port.py new file mode 100644 index 00000000..f0623b16 --- /dev/null +++ b/backend/package/yuxi/channel/domain/port/plugin_registration_api_port.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class PluginRegistrationApiPort(Protocol): + @property + def is_closed(self) -> bool: ... + + def close(self) -> None: ... diff --git a/backend/package/yuxi/channel/domain/repository/__init__.py b/backend/package/yuxi/channel/domain/repository/__init__.py index f5b1bb34..8386208e 100644 --- a/backend/package/yuxi/channel/domain/repository/__init__.py +++ b/backend/package/yuxi/channel/domain/repository/__init__.py @@ -1,12 +1,12 @@ -from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort +from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort as BindingRepositoryPort from yuxi.channel.domain.repository.message_log_repository import ( - MessageLogData, - MessageLogQuery, - MessageLogRepositoryPort, + MessageLogData as MessageLogData, + MessageLogQuery as MessageLogQuery, + MessageLogRepositoryPort as MessageLogRepositoryPort, ) from yuxi.channel.domain.repository.message_repository import ( - ChannelMessageData, - MessageRepositoryPort, + ChannelMessageData as ChannelMessageData, + MessageRepositoryPort as MessageRepositoryPort, ) -from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort -from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort +from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort as OutboxRepositoryPort +from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort as SessionRepositoryPort diff --git a/backend/package/yuxi/channel/domain/repository/binding_repository.py b/backend/package/yuxi/channel/domain/repository/binding_repository.py index 0c85f675..ae906808 100644 --- a/backend/package/yuxi/channel/domain/repository/binding_repository.py +++ b/backend/package/yuxi/channel/domain/repository/binding_repository.py @@ -7,7 +7,9 @@ from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding @runtime_checkable class BindingRepositoryPort(Protocol): - async def find_binding(self, *, channel_type: str, account_id: str, group_id: str) -> ChannelBinding | None: ... + async def find_binding( + self, *, channel_type: str, account_id: str, group_id: str, is_enabled: bool = True + ) -> ChannelBinding | None: ... async def create_binding( self, @@ -35,5 +37,3 @@ class BindingRepositoryPort(Protocol): async def list_bindings( self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50 ) -> tuple[list[ChannelBinding], int]: ... - - async def invalidate_cache(self, *, channel_type: str, account_id: str, group_id: str) -> None: ... diff --git a/backend/package/yuxi/channel/domain/repository/message_log_repository.py b/backend/package/yuxi/channel/domain/repository/message_log_repository.py index 4d16e4c7..3bbd58dd 100644 --- a/backend/package/yuxi/channel/domain/repository/message_log_repository.py +++ b/backend/package/yuxi/channel/domain/repository/message_log_repository.py @@ -58,6 +58,7 @@ class MessageLogRepositoryPort(Protocol): trace_id: str, message_id: str, pipeline_result: str, + status: str, abort_reason: str | None = None, ) -> bool: ... @@ -71,7 +72,7 @@ class MessageLogRepositoryPort(Protocol): error_message: str | None = None, processing_time_ms: int | None = None, agent_config_id: int | None = None, - session_id: int | None = None, + session_id: str | None = None, conversation_id: int | None = None, ) -> bool: ... @@ -79,4 +80,4 @@ class MessageLogRepositoryPort(Protocol): async def get_by_message_id(self, message_id: str) -> list[MessageLogData]: ... - async def query_logs(self, query: MessageLogQuery) -> list[MessageLogData]: ... + async def query_logs(self, query: MessageLogQuery) -> tuple[list[MessageLogData], int]: ... diff --git a/backend/package/yuxi/channel/domain/repository/message_repository.py b/backend/package/yuxi/channel/domain/repository/message_repository.py index 8122d77c..52c6a3b1 100644 --- a/backend/package/yuxi/channel/domain/repository/message_repository.py +++ b/backend/package/yuxi/channel/domain/repository/message_repository.py @@ -23,7 +23,9 @@ class MessageRepositoryPort(Protocol): content: str, message_id: str | None = None, extra_metadata: dict | None = None, - ) -> ChannelMessageData: ... + ) -> ChannelMessageData: + """保存用户消息。当 thread_id 对应的会话不存在时抛出 SessionNotFoundError。""" + ... async def save_assistant_message( self, @@ -31,4 +33,6 @@ class MessageRepositoryPort(Protocol): thread_id: str, content: str, extra_metadata: dict | None = None, - ) -> ChannelMessageData: ... + ) -> ChannelMessageData: + """保存助手消息。当 thread_id 对应的会话不存在时抛出 SessionNotFoundError。""" + ... diff --git a/backend/package/yuxi/channel/domain/repository/outbox_repository.py b/backend/package/yuxi/channel/domain/repository/outbox_repository.py index 7eeb0b5c..da2513c1 100644 --- a/backend/package/yuxi/channel/domain/repository/outbox_repository.py +++ b/backend/package/yuxi/channel/domain/repository/outbox_repository.py @@ -18,7 +18,7 @@ class OutboxRepositoryPort(Protocol): extra_metadata: dict | None = None, ) -> OutboxEntry: ... - async def fetch_pending(self, *, limit: int = 20) -> list[OutboxEntry]: ... + async def fetch_and_lock(self, *, limit: int = 20, worker_id: str = "") -> list[OutboxEntry]: ... async def get_by_id(self, entry_id: int) -> OutboxEntry | None: ... diff --git a/backend/package/yuxi/channel/domain/repository/plugin_audit_log_repository.py b/backend/package/yuxi/channel/domain/repository/plugin_audit_log_repository.py new file mode 100644 index 00000000..113a7125 --- /dev/null +++ b/backend/package/yuxi/channel/domain/repository/plugin_audit_log_repository.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import PluginAuditLog + + +@runtime_checkable +class PluginAuditLogRepositoryPort(Protocol): + async def save(self, log: PluginAuditLog) -> PluginAuditLog: ... + + async def list_by_plugin_id( + self, + plugin_id: str, + *, + offset: int = 0, + limit: int = 20, + ) -> tuple[list[PluginAuditLog], int]: ... + + async def list_by_action( + self, + action: str, + *, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[PluginAuditLog], int]: ... diff --git a/backend/package/yuxi/channel/domain/repository/plugin_config_history_repository.py b/backend/package/yuxi/channel/domain/repository/plugin_config_history_repository.py new file mode 100644 index 00000000..a8b0462e --- /dev/null +++ b/backend/package/yuxi/channel/domain/repository/plugin_config_history_repository.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import PluginConfigHistory + + +@runtime_checkable +class PluginConfigHistoryRepositoryPort(Protocol): + async def save(self, history: PluginConfigHistory) -> PluginConfigHistory: ... + + async def list_by_plugin_id( + self, + plugin_id: str, + *, + offset: int = 0, + limit: int = 20, + ) -> tuple[list[PluginConfigHistory], int]: ... + + async def get_latest(self, plugin_id: str) -> PluginConfigHistory | None: ... diff --git a/backend/package/yuxi/channel/domain/repository/plugin_repository.py b/backend/package/yuxi/channel/domain/repository/plugin_repository.py new file mode 100644 index 00000000..d1e2e6bb --- /dev/null +++ b/backend/package/yuxi/channel/domain/repository/plugin_repository.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import ( + PluginPersistentRecord, +) + + +@runtime_checkable +class PluginRepositoryPort(Protocol): + async def get_by_plugin_id(self, plugin_id: str) -> PluginPersistentRecord | None: ... + + async def get_by_channel_type(self, channel_type: str) -> PluginPersistentRecord | None: ... + + async def list_plugins( + self, + *, + origin: str | None = None, + status: str | None = None, + is_enabled: bool | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[PluginPersistentRecord], int]: ... + + async def create(self, record: PluginPersistentRecord) -> PluginPersistentRecord: ... + + async def update(self, record: PluginPersistentRecord) -> PluginPersistentRecord | None: ... + + async def update_status(self, plugin_id: str, status: str, *, error: str | None = None) -> bool: ... + + async def update_config(self, plugin_id: str, config: dict) -> bool: ... + + async def soft_delete(self, plugin_id: str, *, updated_by: str | None = None) -> bool: ... + + async def list_by_origins(self, origins: list[str]) -> list[PluginPersistentRecord]: ... + + async def bulk_upsert(self, records: list[PluginPersistentRecord]) -> list[PluginPersistentRecord]: ... diff --git a/backend/package/yuxi/channel/domain/service/plugin_registry_domain_service.py b/backend/package/yuxi/channel/domain/service/plugin_registry_domain_service.py new file mode 100644 index 00000000..0144617e --- /dev/null +++ b/backend/package/yuxi/channel/domain/service/plugin_registry_domain_service.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus + + +class PluginRegistryDomainService: + @staticmethod + def can_activate(record: PluginRecord) -> bool: + return record.status not in (PluginStatus.DISCOVERED, PluginStatus.ACTIVE) + + @staticmethod + def can_deactivate(record: PluginRecord) -> bool: + return record.status == PluginStatus.ACTIVE + + @staticmethod + def can_reload(record: PluginRecord) -> bool: + return record.status == PluginStatus.ACTIVE + + @staticmethod + def transition_to_configured(record: PluginRecord) -> None: + if record.status not in (PluginStatus.REGISTERED, PluginStatus.ERROR, PluginStatus.DISABLED): + return + record.status = PluginStatus.CONFIGURED diff --git a/backend/package/yuxi/channel/infrastructure/config/channel_config.py b/backend/package/yuxi/channel/infrastructure/config/channel_config.py index c9eccf18..005ae68e 100644 --- a/backend/package/yuxi/channel/infrastructure/config/channel_config.py +++ b/backend/package/yuxi/channel/infrastructure/config/channel_config.py @@ -1,5 +1,7 @@ from __future__ import annotations +import warnings + from yuxi.utils.logging_config import logger @@ -28,11 +30,15 @@ class ChannelConfig: async def on_config_updated(self, config: dict) -> list[str]: old_auth_token = self._data.get("auth", {}).get("token") + old_auth_password = self._data.get("auth", {}).get("password") self._data = config updated = [] new_auth_token = config.get("auth", {}).get("token") + new_auth_password = config.get("auth", {}).get("password") if old_auth_token != new_auth_token: updated.append("auth_token") + if old_auth_password != new_auth_password: + updated.append("auth_password") return updated @property @@ -58,10 +64,12 @@ class ChannelConfig: @property def hook_mappings(self) -> list[dict]: + warnings.warn("use HooksConfig.mappings instead", DeprecationWarning, stacklevel=2) return self._data.get("hooks", {}).get("mappings", []) @property def feishu_verification_token(self) -> str | None: + warnings.warn("use FeishuConfig.verification_token instead", DeprecationWarning, stacklevel=2) return self._data.get("feishu", {}).get("verification_token") @property @@ -78,6 +86,7 @@ class ChannelConfig: @property def feishu_ws_config(self) -> dict | None: + warnings.warn("use FeishuConfig.ws_config instead", DeprecationWarning, stacklevel=2) feishu = self._data.get("feishu", {}) mode = feishu.get("mode", "webhook") if mode not in ("websocket", "both"): @@ -90,6 +99,7 @@ class ChannelConfig: @property def dingtalk_ws_config(self) -> dict | None: + warnings.warn("use DingTalkConfig.ws_config instead", DeprecationWarning, stacklevel=2) dingtalk = self._data.get("dingtalk", {}) mode = dingtalk.get("mode", "webhook") if mode not in ("websocket", "both"): diff --git a/backend/package/yuxi/channel/infrastructure/metrics/prometheus_metrics.py b/backend/package/yuxi/channel/infrastructure/metrics/prometheus_metrics.py index c84073cf..04700a40 100644 --- a/backend/package/yuxi/channel/infrastructure/metrics/prometheus_metrics.py +++ b/backend/package/yuxi/channel/infrastructure/metrics/prometheus_metrics.py @@ -124,6 +124,12 @@ CIRCUIT_BREAKER_REJECTED = Counter( ["agent_config_id"], ) +VERIFICATION_FAILURES = Counter( + "channel_verification_failures_total", + "Total number of verification failures", + ["channel_type", "reason"], +) + WS_CONNECTION_STATUS = Gauge( "channel_ws_connection_status", "WebSocket connection status (0=disconnected, 1=connected)", @@ -251,3 +257,9 @@ class PrometheusMetricsAdapter(MetricsPort): CIRCUIT_BREAKER_REJECTED.labels(agent_config_id=str(agent_config_id)).inc() except Exception: logger.debug("failed to record circuit_breaker_rejected metric") + + async def record_verification_failed(self, channel_type: str, reason: str) -> None: + try: + VERIFICATION_FAILURES.labels(channel_type=channel_type, reason=reason).inc() + except Exception: + logger.debug("failed to record verification_failed metric") diff --git a/backend/package/yuxi/channel/infrastructure/persistence/converter/binding_converter.py b/backend/package/yuxi/channel/infrastructure/persistence/converter/binding_converter.py index 1d47b372..393d1e27 100644 --- a/backend/package/yuxi/channel/infrastructure/persistence/converter/binding_converter.py +++ b/backend/package/yuxi/channel/infrastructure/persistence/converter/binding_converter.py @@ -11,10 +11,10 @@ def to_domain(row: ChannelBindingModel) -> ChannelBinding: account_id=row.account_id, group_id=row.group_id, agent_config_id=row.agent_config_id, - session_key_strategy=getattr(row, "session_key_strategy", "auto"), + session_key_strategy=row.session_key_strategy, is_enabled=bool(row.is_enabled), created_by=row.created_by, updated_by=row.updated_by, - created_at=row.created_at.isoformat() if row.created_at else "", - updated_at=row.updated_at.isoformat() if row.updated_at else "", + created_at=row.created_at, + updated_at=row.updated_at, ) diff --git a/backend/package/yuxi/channel/infrastructure/persistence/converter/plugin_converter.py b/backend/package/yuxi/channel/infrastructure/persistence/converter/plugin_converter.py new file mode 100644 index 00000000..2b475f46 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/persistence/converter/plugin_converter.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import ( + PluginAuditLog, + PluginConfigHistory, + PluginPersistentRecord, +) +from yuxi.storage.postgres.models_channel import ( + ChannelPlugin, + ChannelPluginAuditLog, + ChannelPluginConfigHistory, +) + + +def to_plugin_domain(row: ChannelPlugin) -> PluginPersistentRecord: + return PluginPersistentRecord( + id=row.id, + plugin_id=row.plugin_id, + name=row.name, + version=row.version, + channel_type=row.channel_type, + origin=row.origin, + status=row.status, + root_dir=row.root_dir, + entry_file=row.entry_file, + manifest=row.manifest or {}, + capabilities=row.capabilities or {}, + config_schema=row.config_schema, + config=row.config or {}, + error=row.error, + is_enabled=bool(row.is_enabled), + registered_at=row.registered_at, + activated_at=row.activated_at, + created_by=row.created_by, + updated_by=row.updated_by, + created_at=row.created_at, + updated_at=row.updated_at, + ) + + +def to_plugin_orm(domain: PluginPersistentRecord) -> ChannelPlugin: + return ChannelPlugin( + plugin_id=domain.plugin_id, + name=domain.name, + version=domain.version, + channel_type=domain.channel_type, + origin=domain.origin, + status=domain.status, + root_dir=domain.root_dir, + entry_file=domain.entry_file, + manifest=domain.manifest or None, + capabilities=domain.capabilities or None, + config_schema=domain.config_schema, + config=domain.config or None, + error=domain.error, + is_enabled=domain.is_enabled, + registered_at=domain.registered_at, + activated_at=domain.activated_at, + created_by=domain.created_by, + updated_by=domain.updated_by, + ) + + +def apply_plugin_updates(row: ChannelPlugin, domain: PluginPersistentRecord) -> None: + row.name = domain.name + row.version = domain.version + row.channel_type = domain.channel_type + row.origin = domain.origin + row.status = domain.status + row.root_dir = domain.root_dir + row.entry_file = domain.entry_file + row.manifest = domain.manifest or None + row.capabilities = domain.capabilities or None + row.config_schema = domain.config_schema + row.config = domain.config or None + row.error = domain.error + row.is_enabled = domain.is_enabled + row.registered_at = domain.registered_at + row.activated_at = domain.activated_at + row.updated_by = domain.updated_by + + +def to_config_history_domain(row: ChannelPluginConfigHistory) -> PluginConfigHistory: + return PluginConfigHistory( + id=row.id, + plugin_id=row.plugin_id, + config=row.config or {}, + changed_by=row.changed_by, + change_reason=row.change_reason, + created_at=row.created_at, + ) + + +def to_audit_log_domain(row: ChannelPluginAuditLog) -> PluginAuditLog: + return PluginAuditLog( + id=row.id, + plugin_id=row.plugin_id, + action=row.action, + old_status=row.old_status, + new_status=row.new_status, + old_version=row.old_version, + new_version=row.new_version, + detail=row.detail, + error_message=row.error_message, + created_by=row.created_by, + created_at=row.created_at, + ) diff --git a/backend/package/yuxi/channel/infrastructure/persistence/converter/session_converter.py b/backend/package/yuxi/channel/infrastructure/persistence/converter/session_converter.py new file mode 100644 index 00000000..7d33ce73 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/persistence/converter/session_converter.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.session.channel_session import ChannelSession +from yuxi.storage.postgres.models_business import Conversation + + +def to_domain(row: Conversation) -> ChannelSession: + return ChannelSession( + id=row.id, + thread_id=row.thread_id, + user_id=row.user_id, + agent_id=row.agent_id, + channel_type=row.channel_type, + channel_session_key=row.channel_session_key, + status=row.status, + title=row.title, + ) diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_agent_config_lookup.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_agent_config_lookup.py new file mode 100644 index 00000000..d1558784 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_agent_config_lookup.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from sqlalchemy import exists as sa_exists, select + +from yuxi.storage.postgres.models_business import AgentConfig + + +class PgAgentConfigLookup: + def __init__(self, session_factory): + self._session_factory = session_factory + + async def exists(self, agent_config_id: int) -> bool: + async with self._session_factory() as db: + stmt = select(sa_exists().where(AgentConfig.id == agent_config_id)) + return await db.scalar(stmt) or False diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_binding_repository.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_binding_repository.py index 0f86073a..d99640a8 100644 --- a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_binding_repository.py +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_binding_repository.py @@ -2,11 +2,16 @@ from __future__ import annotations import json import logging +from dataclasses import fields as dataclass_fields from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from yuxi.channel.domain.exception.agent_config_not_found import AgentConfigNotFoundException +from yuxi.channel.domain.exception.duplicate_binding import DuplicateBindingException from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding from yuxi.channel.domain.port.cache_port import CachePort +from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort from yuxi.channel.infrastructure.persistence.converter.binding_converter import ( to_domain, ) @@ -20,7 +25,36 @@ _BINDING_NULL_CACHE_TTL = 10 _BINDING_NULL_MARKER = "__none__" -class PgBindingRepository: +def _serialize_binding(binding: ChannelBinding) -> str: + data = { + "id": binding.id, + "channel_type": binding.channel_type, + "account_id": binding.account_id, + "group_id": binding.group_id, + "agent_config_id": binding.agent_config_id, + "session_key_strategy": binding.session_key_strategy, + "is_enabled": binding.is_enabled, + "created_by": binding.created_by, + "updated_by": binding.updated_by, + "created_at": binding.created_at.isoformat() if binding.created_at else None, + "updated_at": binding.updated_at.isoformat() if binding.updated_at else None, + } + return json.dumps(data, ensure_ascii=False) + + +def _deserialize_binding(data: dict) -> ChannelBinding: + from datetime import datetime + + valid_keys = {f.name for f in dataclass_fields(ChannelBinding)} + filtered = {k: v for k, v in data.items() if k in valid_keys} + if "created_at" in filtered and isinstance(filtered["created_at"], str): + filtered["created_at"] = datetime.fromisoformat(filtered["created_at"]) + if "updated_at" in filtered and isinstance(filtered["updated_at"], str): + filtered["updated_at"] = datetime.fromisoformat(filtered["updated_at"]) + return ChannelBinding(**filtered) + + +class PgBindingRepository(BindingRepositoryPort): def __init__(self, session_factory, cache_port: CachePort | None = None): self._session_factory = session_factory self._cache = cache_port @@ -28,7 +62,9 @@ class PgBindingRepository: def _cache_key(self, channel_type: str, account_id: str, group_id: str) -> str: return f"channel:binding:{channel_type}:{account_id}:{group_id}" - async def find_binding(self, *, channel_type: str, account_id: str, group_id: str) -> ChannelBinding | None: + async def find_binding( + self, *, channel_type: str, account_id: str, group_id: str, is_enabled: bool = True + ) -> ChannelBinding | None: if self._cache: try: cache_key = self._cache_key(channel_type, account_id, group_id) @@ -37,7 +73,7 @@ class PgBindingRepository: if cached == _BINDING_NULL_MARKER: return None data = json.loads(cached) - return ChannelBinding(**data) + return _deserialize_binding(data) except Exception: logger.warning("Redis cache read failed, falling back to DB") @@ -46,7 +82,7 @@ class PgBindingRepository: ChannelBindingModel.channel_type == channel_type, ChannelBindingModel.account_id == account_id, ChannelBindingModel.group_id == group_id, - ChannelBindingModel.is_enabled.is_(True), + ChannelBindingModel.is_enabled.is_(is_enabled), ChannelBindingModel.is_deleted == 0, ) result = await db.execute(stmt) @@ -66,7 +102,7 @@ class PgBindingRepository: cache_key = self._cache_key(channel_type, account_id, group_id) await self._cache.set( cache_key, - json.dumps(binding.__dict__, ensure_ascii=False), + _serialize_binding(binding), ex=_BINDING_CACHE_TTL, ) except Exception: @@ -94,9 +130,18 @@ class PgBindingRepository: updated_by=created_by, ) db.add(row) - await db.flush() + try: + await db.flush() + except IntegrityError as e: + pgcode = getattr(e.orig, "pgcode", "") or "" + if pgcode == "23503": + raise AgentConfigNotFoundException(agent_config_id) + raise DuplicateBindingException(channel_type, account_id, group_id) await db.refresh(row) - return to_domain(row) + binding = to_domain(row) + + await self._write_positive_cache(binding) + return binding async def update_binding( self, @@ -126,16 +171,14 @@ class PgBindingRepository: row.updated_at = utc_now_naive() await db.flush() - - await self.invalidate_cache( - channel_type=cache_keys[0], - account_id=cache_keys[1], - group_id=cache_keys[2], - ) - await db.refresh(row) binding = to_domain(row) + await self._invalidate_cache( + channel_type=cache_keys[0], + account_id=cache_keys[1], + group_id=cache_keys[2], + ) return binding async def delete_binding(self, binding_id: int, *, updated_by: str | None = None) -> bool: @@ -149,18 +192,19 @@ class PgBindingRepository: if not row: return False + cache_keys = (row.channel_type, row.account_id, row.group_id) + row.is_deleted = 1 row.deleted_at = utc_now_naive() row.updated_by = updated_by await db.flush() - await self.invalidate_cache( - channel_type=row.channel_type, - account_id=row.account_id, - group_id=row.group_id, - ) - + await self._invalidate_cache( + channel_type=cache_keys[0], + account_id=cache_keys[1], + group_id=cache_keys[2], + ) return True async def get_binding(self, binding_id: int) -> ChannelBinding | None: @@ -191,10 +235,23 @@ class PgBindingRepository: result = await db.execute(stmt) return [to_domain(row) for row in result.scalars().all()], total - async def invalidate_cache(self, *, channel_type: str, account_id: str, group_id: str) -> None: + async def _invalidate_cache(self, *, channel_type: str, account_id: str, group_id: str) -> None: if self._cache: try: cache_key = self._cache_key(channel_type, account_id, group_id) await self._cache.delete(cache_key) except Exception: logger.warning("Redis cache invalidation failed, cache will expire via TTL") + + async def _write_positive_cache(self, binding: ChannelBinding) -> None: + if not self._cache: + return + try: + cache_key = self._cache_key(binding.channel_type, binding.account_id, binding.group_id) + await self._cache.set( + cache_key, + _serialize_binding(binding), + ex=_BINDING_CACHE_TTL, + ) + except Exception: + logger.warning("Redis positive cache write failed, cache will be populated on next read") diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_message_log_repository.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_message_log_repository.py index b3725c44..6c4e9be2 100644 --- a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_message_log_repository.py +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_message_log_repository.py @@ -7,6 +7,7 @@ from sqlalchemy import select, update from yuxi.channel.domain.repository.message_log_repository import ( MessageLogData, MessageLogQuery, + MessageLogRepositoryPort, ) from yuxi.storage.postgres.models_channel import ChannelMessageLog from yuxi.utils.datetime_utils import utc_now_naive @@ -37,7 +38,7 @@ def _to_data(row: ChannelMessageLog) -> MessageLogData: ) -class PgMessageLogRepository: +class PgMessageLogRepository(MessageLogRepositoryPort): def __init__(self, session_factory): self._session_factory = session_factory @@ -81,6 +82,7 @@ class PgMessageLogRepository: trace_id: str, message_id: str, pipeline_result: str, + status: str, abort_reason: str | None = None, ) -> bool: async with self._session_factory() as db: @@ -94,7 +96,7 @@ class PgMessageLogRepository: .values( pipeline_result=pipeline_result, abort_reason=abort_reason, - status="processing" if pipeline_result == "accepted" else "completed", + status=status, updated_at=utc_now_naive(), ) ) @@ -111,7 +113,7 @@ class PgMessageLogRepository: error_message: str | None = None, processing_time_ms: int | None = None, agent_config_id: int | None = None, - session_id: int | None = None, + session_id: str | None = None, conversation_id: int | None = None, ) -> bool: values: dict = { @@ -169,21 +171,26 @@ class PgMessageLogRepository: result = await db.execute(stmt) return [_to_data(row) for row in result.scalars().all()] - async def query_logs(self, query: MessageLogQuery) -> list[MessageLogData]: + async def query_logs(self, query: MessageLogQuery) -> tuple[list[MessageLogData], int]: + from sqlalchemy import func + async with self._session_factory() as db: - stmt = select(ChannelMessageLog).where(ChannelMessageLog.is_deleted == 0) + base = select(ChannelMessageLog).where(ChannelMessageLog.is_deleted == 0) if query.channel_type: - stmt = stmt.where(ChannelMessageLog.channel_type == query.channel_type) + base = base.where(ChannelMessageLog.channel_type == query.channel_type) if query.conversation_id: - stmt = stmt.where(ChannelMessageLog.conversation_id == query.conversation_id) + base = base.where(ChannelMessageLog.conversation_id == query.conversation_id) if query.status: - stmt = stmt.where(ChannelMessageLog.status == query.status) + base = base.where(ChannelMessageLog.status == query.status) if query.direction: - stmt = stmt.where(ChannelMessageLog.direction == query.direction) + base = base.where(ChannelMessageLog.direction == query.direction) if query.pipeline_result: - stmt = stmt.where(ChannelMessageLog.pipeline_result == query.pipeline_result) + base = base.where(ChannelMessageLog.pipeline_result == query.pipeline_result) if query.worker_result: - stmt = stmt.where(ChannelMessageLog.worker_result == query.worker_result) - stmt = stmt.order_by(ChannelMessageLog.created_at.desc()).offset(query.offset).limit(query.limit) + base = base.where(ChannelMessageLog.worker_result == query.worker_result) + count_stmt = select(func.count()).select_from(base.subquery()) + count_result = await db.execute(count_stmt) + total = count_result.scalar() or 0 + stmt = base.order_by(ChannelMessageLog.created_at.desc()).offset(query.offset).limit(query.limit) result = await db.execute(stmt) - return [_to_data(row) for row in result.scalars().all()] + return [_to_data(row) for row in result.scalars().all()], total diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_message_repository.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_message_repository.py index 0a822247..bdfd911e 100644 --- a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_message_repository.py +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_message_repository.py @@ -2,18 +2,28 @@ from __future__ import annotations import logging +from sqlalchemy import select + +from yuxi.channel.domain.exception.session_exception import SessionNotFoundError from yuxi.channel.domain.repository.message_repository import ( ChannelMessageData, + MessageRepositoryPort, ) -from yuxi.repositories.conversation_repository import ConversationRepository +from yuxi.storage.postgres.models_business import Conversation, Message +from yuxi.utils.datetime_utils import utc_now_naive logger = logging.getLogger(__name__) -class PgMessageRepository: +class PgMessageRepository(MessageRepositoryPort): def __init__(self, session_factory): self._session_factory = session_factory + async def _find_conversation(self, db, thread_id: str): + stmt = select(Conversation).where(Conversation.thread_id == thread_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + async def save_user_message( self, *, @@ -27,13 +37,21 @@ class PgMessageRepository: meta["channel_message_id"] = message_id async with self._session_factory() as db: - conv_repo = ConversationRepository(db) - msg = await conv_repo.add_message_by_thread_id( - thread_id=thread_id, + conv = await self._find_conversation(db, thread_id) + if not conv: + raise SessionNotFoundError(thread_id) + + msg = Message( + conversation_id=conv.id, role="user", content=content, + message_type="text", extra_metadata=meta, ) + db.add(msg) + conv.updated_at = utc_now_naive() + await db.flush() + await db.refresh(msg) return ChannelMessageData( id=msg.id, @@ -52,13 +70,21 @@ class PgMessageRepository: extra_metadata: dict | None = None, ) -> ChannelMessageData: async with self._session_factory() as db: - conv_repo = ConversationRepository(db) - msg = await conv_repo.add_message_by_thread_id( - thread_id=thread_id, + conv = await self._find_conversation(db, thread_id) + if not conv: + raise SessionNotFoundError(thread_id) + + msg = Message( + conversation_id=conv.id, role="assistant", content=content, + message_type="text", extra_metadata=extra_metadata, ) + db.add(msg) + conv.updated_at = utc_now_naive() + await db.flush() + await db.refresh(msg) return ChannelMessageData( id=msg.id, diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_outbox_repository.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_outbox_repository.py index 19f275a5..8af8a4d1 100644 --- a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_outbox_repository.py +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_outbox_repository.py @@ -4,10 +4,12 @@ import json import logging from datetime import timedelta -import redis.asyncio as aioredis -from sqlalchemy import select +from sqlalchemy import select, update from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry +from yuxi.channel.domain.model.outbox.outbox_status import OutboxStatus +from yuxi.channel.domain.port.cache_port import CachePort +from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort from yuxi.storage.postgres.models_channel import ChannelOutbox from yuxi.utils.datetime_utils import utc_now_naive @@ -16,6 +18,7 @@ logger = logging.getLogger(__name__) _BASE_DELAY = 5 _MAX_DELAY = 300 _OUTBOX_NOTIFY_CHANNEL = "channel:outbox:notify" +_PROCESSING_TIMEOUT_MINUTES = 5 def _to_domain(row: ChannelOutbox) -> OutboxEntry: @@ -28,7 +31,7 @@ def _to_domain(row: ChannelOutbox) -> OutboxEntry: status=row.status, retry_count=row.retry_count, max_retries=row.max_retries, - next_retry_at=row.next_retry_at.isoformat() if row.next_retry_at else None, + next_retry_at=row.next_retry_at, last_error=row.last_error, trace_id=row.trace_id, extra_metadata=row.extra_metadata, @@ -40,14 +43,14 @@ def _calc_next_retry(retry_count: int): return utc_now_naive() + timedelta(seconds=delay) -class PgOutboxRepository: +class PgOutboxRepository(OutboxRepositoryPort): def __init__( self, session_factory, - redis: aioredis.Redis | None = None, + cache_port: CachePort | None = None, ): self._session_factory = session_factory - self._redis = redis + self._cache = cache_port async def enqueue( self, @@ -65,7 +68,7 @@ class PgOutboxRepository: session_id=session_id, channel_type=channel_type, content=content, - status="pending", + status=OutboxStatus.PENDING, trace_id=trace_id, extra_metadata=extra_metadata, ) @@ -74,9 +77,9 @@ class PgOutboxRepository: await db.refresh(row) entry = _to_domain(row) - if self._redis: + if self._cache: try: - await self._redis.publish( + await self._cache.publish( _OUTBOX_NOTIFY_CHANNEL, json.dumps({"entry_id": entry.id}), ) @@ -85,13 +88,25 @@ class PgOutboxRepository: return entry - async def fetch_pending(self, *, limit: int = 20) -> list[OutboxEntry]: + async def fetch_and_lock(self, *, limit: int = 20, worker_id: str = "") -> list[OutboxEntry]: now = utc_now_naive() + processing_timeout = timedelta(minutes=_PROCESSING_TIMEOUT_MINUTES) async with self._session_factory() as db: + timeout_stmt = ( + update(ChannelOutbox) + .where( + ChannelOutbox.status == OutboxStatus.PROCESSING, + ChannelOutbox.updated_at < now - processing_timeout, + ChannelOutbox.is_deleted == 0, + ) + .values(status=OutboxStatus.PENDING, updated_at=utc_now_naive()) + ) + await db.execute(timeout_stmt) + stmt = ( select(ChannelOutbox) .where( - ChannelOutbox.status.in_(["pending", "retrying"]), + ChannelOutbox.status.in_([OutboxStatus.PENDING, OutboxStatus.RETRYING]), ChannelOutbox.is_deleted == 0, ) .where((ChannelOutbox.next_retry_at.is_(None)) | (ChannelOutbox.next_retry_at <= now)) @@ -100,7 +115,15 @@ class PgOutboxRepository: .with_for_update(skip_locked=True) ) result = await db.execute(stmt) - return [_to_domain(row) for row in result.scalars().all()] + rows = result.scalars().all() + if not rows: + return [] + + for row in rows: + row.status = OutboxStatus.PROCESSING + + await db.flush() + return [_to_domain(row) for row in rows] async def get_by_id(self, entry_id: int) -> OutboxEntry | None: async with self._session_factory() as db: @@ -124,60 +147,64 @@ class PgOutboxRepository: return None row.retry_count += 1 - row.status = "retrying" - row.next_retry_at = _calc_next_retry(row.retry_count) - if last_error: - row.last_error = last_error + if row.retry_count >= row.max_retries: + row.status = OutboxStatus.DEAD + row.last_error = last_error or "max_retries_exceeded" + else: + row.status = OutboxStatus.RETRYING + row.next_retry_at = _calc_next_retry(row.retry_count) + if last_error: + row.last_error = last_error + + row.updated_at = utc_now_naive() await db.flush() await db.refresh(row) return _to_domain(row) async def mark_sent(self, entry_id: int) -> bool: async with self._session_factory() as db: - stmt = select(ChannelOutbox).where( - ChannelOutbox.id == entry_id, - ChannelOutbox.is_deleted == 0, + stmt = ( + update(ChannelOutbox) + .where( + ChannelOutbox.id == entry_id, + ChannelOutbox.status == OutboxStatus.PROCESSING, + ChannelOutbox.is_deleted == 0, + ) + .values(status=OutboxStatus.SENT, updated_at=utc_now_naive()) ) result = await db.execute(stmt) - row = result.scalar_one_or_none() - if not row: - return False - - row.status = "sent" await db.flush() - return True + return result.rowcount > 0 async def mark_dead(self, entry_id: int, *, last_error: str) -> bool: async with self._session_factory() as db: - stmt = select(ChannelOutbox).where( - ChannelOutbox.id == entry_id, - ChannelOutbox.is_deleted == 0, + stmt = ( + update(ChannelOutbox) + .where( + ChannelOutbox.id == entry_id, + ChannelOutbox.status == OutboxStatus.PROCESSING, + ChannelOutbox.is_deleted == 0, + ) + .values(status=OutboxStatus.DEAD, last_error=last_error, updated_at=utc_now_naive()) ) result = await db.execute(stmt) - row = result.scalar_one_or_none() - if not row: - return False - - row.status = "dead" - row.last_error = last_error await db.flush() - return True + return result.rowcount > 0 async def mark_failed(self, entry_id: int, *, last_error: str) -> bool: async with self._session_factory() as db: - stmt = select(ChannelOutbox).where( - ChannelOutbox.id == entry_id, - ChannelOutbox.is_deleted == 0, + stmt = ( + update(ChannelOutbox) + .where( + ChannelOutbox.id == entry_id, + ChannelOutbox.status == OutboxStatus.PROCESSING, + ChannelOutbox.is_deleted == 0, + ) + .values(status=OutboxStatus.FAILED, last_error=last_error, updated_at=utc_now_naive()) ) result = await db.execute(stmt) - row = result.scalar_one_or_none() - if not row: - return False - - row.status = "failed" - row.last_error = last_error await db.flush() - return True + return result.rowcount > 0 async def list_outbox( self, diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_audit_log_repository.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_audit_log_repository.py new file mode 100644 index 00000000..093ebe03 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_audit_log_repository.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import logging + +from sqlalchemy import desc, func, select + +from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import ( + PluginAuditLog, +) +from yuxi.channel.infrastructure.persistence.converter.plugin_converter import ( + to_audit_log_domain, +) +from yuxi.storage.postgres.models_channel import ChannelPluginAuditLog + +logger = logging.getLogger(__name__) + + +class PgPluginAuditLogRepository: + def __init__(self, session_factory): + self._session_factory = session_factory + + async def save(self, log: PluginAuditLog) -> PluginAuditLog: + async with self._session_factory() as db: + orm = ChannelPluginAuditLog( + plugin_id=log.plugin_id, + action=log.action, + old_status=log.old_status, + new_status=log.new_status, + old_version=log.old_version, + new_version=log.new_version, + detail=log.detail, + error_message=log.error_message, + created_by=log.created_by, + ) + db.add(orm) + await db.flush() + await db.refresh(orm) + return to_audit_log_domain(orm) + + async def list_by_plugin_id( + self, + plugin_id: str, + *, + offset: int = 0, + limit: int = 20, + ) -> tuple[list[PluginAuditLog], int]: + async with self._session_factory() as db: + base = select(ChannelPluginAuditLog).where( + ChannelPluginAuditLog.plugin_id == plugin_id, + ChannelPluginAuditLog.is_deleted == 0, + ) + + count_stmt = select(func.count()).select_from(base.subquery()) + count_result = await db.execute(count_stmt) + total = count_result.scalar() or 0 + + stmt = base.order_by(desc(ChannelPluginAuditLog.created_at)).offset(offset).limit(limit) + result = await db.execute(stmt) + records = [to_audit_log_domain(row) for row in result.scalars().all()] + return records, total + + async def list_by_action( + self, + action: str, + *, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[PluginAuditLog], int]: + async with self._session_factory() as db: + base = select(ChannelPluginAuditLog).where( + ChannelPluginAuditLog.action == action, + ChannelPluginAuditLog.is_deleted == 0, + ) + + count_stmt = select(func.count()).select_from(base.subquery()) + count_result = await db.execute(count_stmt) + total = count_result.scalar() or 0 + + stmt = base.order_by(desc(ChannelPluginAuditLog.created_at)).offset(offset).limit(limit) + result = await db.execute(stmt) + records = [to_audit_log_domain(row) for row in result.scalars().all()] + return records, total diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_config_history_repository.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_config_history_repository.py new file mode 100644 index 00000000..95ffe8c5 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_config_history_repository.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import logging + +from sqlalchemy import desc, func, select + +from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import ( + PluginConfigHistory, +) +from yuxi.channel.infrastructure.persistence.converter.plugin_converter import ( + to_config_history_domain, +) +from yuxi.storage.postgres.models_channel import ChannelPluginConfigHistory + +logger = logging.getLogger(__name__) + + +class PgPluginConfigHistoryRepository: + def __init__(self, session_factory): + self._session_factory = session_factory + + async def save(self, history: PluginConfigHistory) -> PluginConfigHistory: + async with self._session_factory() as db: + orm = ChannelPluginConfigHistory( + plugin_id=history.plugin_id, + config=history.config or None, + changed_by=history.changed_by, + change_reason=history.change_reason, + created_by=history.changed_by, + ) + db.add(orm) + await db.flush() + await db.refresh(orm) + return to_config_history_domain(orm) + + async def list_by_plugin_id( + self, + plugin_id: str, + *, + offset: int = 0, + limit: int = 20, + ) -> tuple[list[PluginConfigHistory], int]: + async with self._session_factory() as db: + base = select(ChannelPluginConfigHistory).where( + ChannelPluginConfigHistory.plugin_id == plugin_id, + ChannelPluginConfigHistory.is_deleted == 0, + ) + + count_stmt = select(func.count()).select_from(base.subquery()) + count_result = await db.execute(count_stmt) + total = count_result.scalar() or 0 + + stmt = base.order_by(desc(ChannelPluginConfigHistory.created_at)).offset(offset).limit(limit) + result = await db.execute(stmt) + records = [to_config_history_domain(row) for row in result.scalars().all()] + return records, total + + async def get_latest(self, plugin_id: str) -> PluginConfigHistory | None: + async with self._session_factory() as db: + stmt = ( + select(ChannelPluginConfigHistory) + .where( + ChannelPluginConfigHistory.plugin_id == plugin_id, + ChannelPluginConfigHistory.is_deleted == 0, + ) + .order_by(desc(ChannelPluginConfigHistory.created_at)) + .limit(1) + ) + result = await db.execute(stmt) + row = result.scalar_one_or_none() + return to_config_history_domain(row) if row else None diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_repository.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_repository.py new file mode 100644 index 00000000..07170802 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_plugin_repository.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import logging + +from sqlalchemy import func, select, update +from sqlalchemy.dialects.postgresql import insert + +from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import ( + PluginPersistentRecord, +) +from yuxi.channel.domain.repository.plugin_repository import PluginRepositoryPort +from yuxi.channel.infrastructure.persistence.converter.plugin_converter import ( + apply_plugin_updates, + to_plugin_domain, + to_plugin_orm, +) +from yuxi.storage.postgres.models_channel import ChannelPlugin +from yuxi.utils.datetime_utils import utc_now_naive + +logger = logging.getLogger(__name__) + + +class PgPluginRepository(PluginRepositoryPort): + def __init__(self, session_factory): + self._session_factory = session_factory + + async def get_by_plugin_id(self, plugin_id: str) -> PluginPersistentRecord | None: + async with self._session_factory() as db: + stmt = select(ChannelPlugin).where( + ChannelPlugin.plugin_id == plugin_id, + ChannelPlugin.is_deleted == 0, + ) + result = await db.execute(stmt) + row = result.scalar_one_or_none() + return to_plugin_domain(row) if row else None + + async def get_by_channel_type(self, channel_type: str) -> PluginPersistentRecord | None: + async with self._session_factory() as db: + stmt = select(ChannelPlugin).where( + ChannelPlugin.channel_type == channel_type, + ChannelPlugin.is_deleted == 0, + ) + result = await db.execute(stmt) + row = result.scalar_one_or_none() + return to_plugin_domain(row) if row else None + + async def list_plugins( + self, + *, + origin: str | None = None, + status: str | None = None, + is_enabled: bool | None = None, + offset: int = 0, + limit: int = 50, + ) -> tuple[list[PluginPersistentRecord], int]: + async with self._session_factory() as db: + base = select(ChannelPlugin).where(ChannelPlugin.is_deleted == 0) + + if origin: + base = base.where(ChannelPlugin.origin == origin) + if status: + base = base.where(ChannelPlugin.status == status) + if is_enabled is not None: + base = base.where(ChannelPlugin.is_enabled.is_(is_enabled)) + + count_stmt = select(func.count()).select_from(base.subquery()) + count_result = await db.execute(count_stmt) + total = count_result.scalar() or 0 + + stmt = base.order_by(ChannelPlugin.id.asc()).offset(offset).limit(limit) + result = await db.execute(stmt) + records = [to_plugin_domain(row) for row in result.scalars().all()] + return records, total + + async def create(self, record: PluginPersistentRecord) -> PluginPersistentRecord: + async with self._session_factory() as db: + orm = to_plugin_orm(record) + db.add(orm) + await db.flush() + await db.refresh(orm) + return to_plugin_domain(orm) + + async def update(self, record: PluginPersistentRecord) -> PluginPersistentRecord | None: + if not record.plugin_id: + return None + + async with self._session_factory() as db: + stmt = select(ChannelPlugin).where( + ChannelPlugin.plugin_id == record.plugin_id, + ChannelPlugin.is_deleted == 0, + ) + result = await db.execute(stmt) + row = result.scalar_one_or_none() + if not row: + return None + + apply_plugin_updates(row, record) + row.updated_at = utc_now_naive() + + await db.flush() + await db.refresh(row) + return to_plugin_domain(row) + + async def update_status(self, plugin_id: str, status: str, *, error: str | None = None) -> bool: + async with self._session_factory() as db: + values: dict = {"status": status, "updated_at": utc_now_naive()} + if error is not None: + values["error"] = error + stmt = ( + update(ChannelPlugin) + .where( + ChannelPlugin.plugin_id == plugin_id, + ChannelPlugin.is_deleted == 0, + ) + .values(**values) + ) + result = await db.execute(stmt) + await db.flush() + return result.rowcount > 0 + + async def update_config(self, plugin_id: str, config: dict) -> bool: + async with self._session_factory() as db: + stmt = ( + update(ChannelPlugin) + .where( + ChannelPlugin.plugin_id == plugin_id, + ChannelPlugin.is_deleted == 0, + ) + .values(config=config, updated_at=utc_now_naive()) + ) + result = await db.execute(stmt) + await db.flush() + return result.rowcount > 0 + + async def soft_delete(self, plugin_id: str, *, updated_by: str | None = None) -> bool: + async with self._session_factory() as db: + stmt = ( + update(ChannelPlugin) + .where( + ChannelPlugin.plugin_id == plugin_id, + ChannelPlugin.is_deleted == 0, + ) + .values( + is_deleted=1, + deleted_at=utc_now_naive(), + updated_by=updated_by, + updated_at=utc_now_naive(), + ) + ) + result = await db.execute(stmt) + await db.flush() + return result.rowcount > 0 + + async def list_by_origins(self, origins: list[str]) -> list[PluginPersistentRecord]: + if not origins: + return [] + + async with self._session_factory() as db: + stmt = select(ChannelPlugin).where( + ChannelPlugin.origin.in_(origins), + ChannelPlugin.is_deleted == 0, + ) + result = await db.execute(stmt) + return [to_plugin_domain(row) for row in result.scalars().all()] + + async def bulk_upsert(self, records: list[PluginPersistentRecord]) -> list[PluginPersistentRecord]: + if not records: + return [] + + async with self._session_factory() as db: + values = [] + for r in records: + values.append( + { + "plugin_id": r.plugin_id, + "name": r.name, + "version": r.version, + "channel_type": r.channel_type, + "origin": r.origin, + "status": r.status, + "root_dir": r.root_dir, + "entry_file": r.entry_file, + "manifest": r.manifest or None, + "capabilities": r.capabilities or None, + "config_schema": r.config_schema, + "config": r.config or None, + "error": r.error, + "is_enabled": r.is_enabled, + "is_deleted": 0, + "registered_at": r.registered_at, + "updated_at": utc_now_naive(), + } + ) + + stmt = insert(ChannelPlugin).values(values) + stmt = stmt.on_conflict_do_update( + index_elements=["plugin_id"], + set_={ + "name": stmt.excluded.name, + "version": stmt.excluded.version, + "channel_type": stmt.excluded.channel_type, + "origin": stmt.excluded.origin, + "status": stmt.excluded.status, + "root_dir": stmt.excluded.root_dir, + "entry_file": stmt.excluded.entry_file, + "manifest": stmt.excluded.manifest, + "capabilities": stmt.excluded.capabilities, + "config_schema": stmt.excluded.config_schema, + "config": stmt.excluded.config, + "error": stmt.excluded.error, + "is_enabled": stmt.excluded.is_enabled, + "is_deleted": ChannelPlugin.is_deleted, + "deleted_at": ChannelPlugin.deleted_at, + "updated_at": stmt.excluded.updated_at, + }, + ) + await db.execute(stmt) + await db.flush() + + plugin_ids = [r.plugin_id for r in records] + select_stmt = select(ChannelPlugin).where( + ChannelPlugin.plugin_id.in_(plugin_ids), + ChannelPlugin.is_deleted == 0, + ) + result = await db.execute(select_stmt) + return [to_plugin_domain(row) for row in result.scalars().all()] diff --git a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_session_repository.py b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_session_repository.py index f582b35b..0d8d0809 100644 --- a/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_session_repository.py +++ b/backend/package/yuxi/channel/infrastructure/persistence/repository/pg_session_repository.py @@ -10,6 +10,10 @@ from sqlalchemy.exc import IntegrityError from yuxi.channel.domain.model.session.channel_session import ChannelSession from yuxi.channel.domain.port.cache_port import CachePort +from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort +from yuxi.channel.infrastructure.persistence.converter.session_converter import ( + to_domain, +) from yuxi.storage.postgres.models_business import Conversation logger = logging.getLogger(__name__) @@ -17,20 +21,7 @@ logger = logging.getLogger(__name__) _LOCK_TIMEOUT = 10 -def _to_domain(row: Conversation) -> ChannelSession: - return ChannelSession( - id=row.id, - thread_id=row.thread_id, - user_id=row.user_id, - agent_id=row.agent_id, - channel_type=row.channel_type, - channel_session_key=row.channel_session_key, - status=row.status, - title=row.title, - ) - - -class PgSessionRepository: +class PgSessionRepository(SessionRepositoryPort): def __init__( self, session_factory, @@ -56,22 +47,24 @@ class PgSessionRepository: existing = await self._find_existing(channel_type, session_key, agent_id) if existing: - return _to_domain(existing) + return to_domain(existing) lock_key = f"channel:session_lock:{session_key}:{agent_id}" + lock_value = str(uuid_lib.uuid4()) lock_acquired = False if self._cache: try: - lock_acquired = await self._cache.set(lock_key, "1", ex=self._lock_timeout, nx=True) + lock_acquired = await self._cache.set(lock_key, lock_value, ex=self._lock_timeout, nx=True) if not lock_acquired: await asyncio.sleep(0.1) existing = await self._find_existing(channel_type, session_key, agent_id) if existing: - return _to_domain(existing) - lock_acquired = await self._cache.set(lock_key, "1", ex=self._lock_timeout, nx=True) + return to_domain(existing) + lock_acquired = await self._cache.set(lock_key, lock_value, ex=self._lock_timeout, nx=True) except Exception: logger.warning("Redis lock failed, proceeding without lock") + lock_acquired = False try: async with self._session_factory() as db: @@ -89,19 +82,29 @@ class PgSessionRepository: db.add(conv) await db.flush() await db.refresh(conv) - return _to_domain(conv) + return to_domain(conv) except IntegrityError: existing = await self._find_existing(channel_type, session_key, agent_id) if existing: - return _to_domain(existing) + return to_domain(existing) raise finally: if self._cache and lock_acquired: try: - await self._cache.delete(lock_key) + await self._safe_release_lock(lock_key, lock_value) except Exception: pass + async def _safe_release_lock(self, lock_key: str, lock_value: str) -> None: + lua_script = """ + if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + """ + await self._cache.eval(lua_script, keys=[lock_key], args=[lock_value]) + async def get_by_thread_id(self, thread_id: str) -> ChannelSession | None: async with self._session_factory() as db: stmt = select(Conversation).where( @@ -110,7 +113,7 @@ class PgSessionRepository: ) result = await db.execute(stmt) row = result.scalar_one_or_none() - return _to_domain(row) if row else None + return to_domain(row) if row else None async def _find_existing(self, channel_type: str, session_key: str, agent_id: str) -> Conversation | None: async with self._session_factory() as db: diff --git a/backend/package/yuxi/channel/infrastructure/plugin/__init__.py b/backend/package/yuxi/channel/infrastructure/plugin/__init__.py new file mode 100644 index 00000000..45d76e7a --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/__init__.py @@ -0,0 +1,11 @@ +from yuxi.channel.infrastructure.plugin.registry_impl import ( + PluginRegistryImpl as PluginRegistryImpl, +) +from yuxi.channel.infrastructure.plugin.registry_impl import ( + get_registry as get_registry, +) +from yuxi.channel.infrastructure.plugin.registry_impl import ( + set_registry as set_registry, +) + +__all__ = ["PluginRegistryImpl", "get_registry", "set_registry"] diff --git a/backend/package/yuxi/channel/infrastructure/plugin/api_guard.py b/backend/package/yuxi/channel/infrastructure/plugin/api_guard.py new file mode 100644 index 00000000..b4a42f49 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/api_guard.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + + +class PluginRegistrationApi: + def __init__(self, plugin_id: str, config: dict | None = None): + self._plugin_id = plugin_id + self._config = config or {} + self._closed = False + + @property + def is_closed(self) -> bool: + return self._closed + + def close(self) -> None: + self._closed = True + + @property + def config(self) -> dict: + if self._closed: + raise RuntimeError(f"registration API closed for plugin: {self._plugin_id}") + return self._config + + +class ApiGuard: + def create( + self, plugin_id: str, config: dict | None = None, logger_: logging.Logger | None = None + ) -> PluginRegistrationApi: + api = PluginRegistrationApi(plugin_id, config) + if logger_: + logger_.debug("created registration API for plugin: %s", plugin_id) + return api diff --git a/backend/package/yuxi/channel/infrastructure/plugin/bundled/__init__.py b/backend/package/yuxi/channel/infrastructure/plugin/bundled/__init__.py new file mode 100644 index 00000000..184f6d2b --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/bundled/__init__.py @@ -0,0 +1,5 @@ +from yuxi.channel.infrastructure.plugin.bundled.register_builtin import ( + register_builtin_plugins as register_builtin_plugins, +) + +__all__ = ["register_builtin_plugins"] diff --git a/backend/package/yuxi/channel/infrastructure/plugin/bundled/register_builtin.py b/backend/package/yuxi/channel/infrastructure/plugin/bundled/register_builtin.py new file mode 100644 index 00000000..cf77419c --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/bundled/register_builtin.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import logging + +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry +from yuxi.channel.domain.model.plugin_registry.plugin_registry_factory import PluginRegistryFactory +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + +logger = logging.getLogger(__name__) + + +def _extract_capabilities(adapter_cls: type) -> ChannelCapabilities: + try: + if hasattr(adapter_cls, "capabilities"): + caps = getattr(adapter_cls, "capabilities", None) + if isinstance(caps, ChannelCapabilities): + return caps + + instance = adapter_cls.__new__(adapter_cls) + if hasattr(instance, "capabilities"): + caps = instance.capabilities + if isinstance(caps, ChannelCapabilities): + return caps + except Exception: + pass + return ChannelCapabilities() + + +async def register_builtin_plugins(registry: PluginRegistry) -> None: + from yuxi.channel.channels._registry import get_compat_registry + + compat = get_compat_registry() + for name, adapter_cls in compat.items(): + caps = _extract_capabilities(adapter_cls) + record = PluginRegistryFactory.create_builtin( + plugin_id=name, + name=name.capitalize(), + channel_type=name, + adapter_class=adapter_cls, + capabilities=caps, + ) + registry.register_builtin(record) + logger.info("registered builtin plugin: %s", name) diff --git a/backend/package/yuxi/channel/infrastructure/plugin/discovery.py b/backend/package/yuxi/channel/infrastructure/plugin/discovery.py new file mode 100644 index 00000000..a8eb7075 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/discovery.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.infrastructure.plugin.manifest_parser import ManifestParser + +logger = logging.getLogger(__name__) + + +@dataclass +class PluginCandidate: + plugin_id: str + source: PluginSource + manifest_path: str | None = None + manifest_data: dict | None = None + + +class PluginDiscovery: + MANIFEST_FILENAME = "yuxi.plugin.yaml" + + def __init__(self, manifest_parser: ManifestParser | None = None): + self._parser = manifest_parser or ManifestParser() + + async def discover_workspace(self, base_dir: str = "plugins") -> list[PluginCandidate]: + return await self._scan_directory(base_dir, PluginOrigin.WORKSPACE) + + async def discover_installed(self, base_dir: str = "installed/plugins") -> list[PluginCandidate]: + return await self._scan_directory(base_dir, PluginOrigin.INSTALLED) + + async def discover_all(self) -> list[PluginCandidate]: + candidates = [] + candidates.extend(await self.discover_workspace()) + candidates.extend(await self.discover_installed()) + return candidates + + async def find(self, plugin_id: str) -> PluginCandidate | None: + all_candidates = await self.discover_all() + for candidate in all_candidates: + if candidate.plugin_id == plugin_id: + return candidate + return None + + async def _scan_directory(self, base_dir: str, origin: PluginOrigin) -> list[PluginCandidate]: + candidates = [] + base_path = Path(base_dir) + if not base_path.exists(): + return candidates + + for plugin_dir in sorted(base_path.iterdir()): + if not plugin_dir.is_dir(): + continue + if plugin_dir.name.startswith("_") or plugin_dir.name.startswith("."): + continue + + manifest_path = plugin_dir / self.MANIFEST_FILENAME + if not manifest_path.exists(): + logger.debug("skipping %s: no %s found", plugin_dir, self.MANIFEST_FILENAME) + continue + + try: + result = await self._parser.parse_file(str(manifest_path)) + manifest = result.manifest + + source = PluginSource( + origin=origin, + root_dir=str(plugin_dir), + entry_file=str(plugin_dir / manifest.entry) if manifest.entry else "", + ) + + candidates.append( + PluginCandidate( + plugin_id=manifest.id, + source=source, + manifest_path=str(manifest_path), + manifest_data=result.raw, + ) + ) + except Exception as exc: + logger.warning("failed to parse manifest in %s: %s", plugin_dir, exc) + + return candidates diff --git a/backend/package/yuxi/channel/infrastructure/plugin/loader.py b/backend/package/yuxi/channel/infrastructure/plugin/loader.py new file mode 100644 index 00000000..d56834c4 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/loader.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import importlib +import importlib.util +import logging +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class PluginLoader: + async def load(self, entry_file: str) -> object: + try: + path = Path(entry_file) + if path.exists() and path.is_file(): + module_name = entry_file.replace("/", ".").replace("\\", ".").removesuffix(".py") + if module_name in sys.modules: + return sys.modules[module_name] + + spec = importlib.util.spec_from_file_location(module_name, str(path)) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + module_name = entry_file.replace("/", ".").replace("\\", ".").removesuffix(".py") + return importlib.import_module(module_name) + except Exception as exc: + logger.error("failed to load plugin module %s: %s", entry_file, exc) + raise + + def extract_adapter_class(self, module: object) -> type | None: + for attr_name in dir(module): + if attr_name.startswith("_"): + continue + attr = getattr(module, attr_name, None) + if attr is None or not isinstance(attr, type): + continue + if not hasattr(attr, "get_default_config"): + continue + if not hasattr(attr, "capabilities"): + continue + if not hasattr(attr, "open") or not hasattr(attr, "close"): + continue + return attr + return None diff --git a/backend/package/yuxi/channel/infrastructure/plugin/manifest_parser.py b/backend/package/yuxi/channel/infrastructure/plugin/manifest_parser.py new file mode 100644 index 00000000..32c2704c --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/manifest_parser.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + +logger = logging.getLogger(__name__) + + +@dataclass +class ManifestParseResult: + manifest: PluginManifest + raw: dict + + +class ManifestParser: + def parse(self, data: dict) -> ManifestParseResult: + try: + plugin_id = data.get("id", "") + name = data.get("name", "") + version = data.get("version", "0.0.0") + channel_type = data.get("channel_type", "") + entry = data.get("entry", "") + dependencies = data.get("dependencies", []) + config_schema = data.get("config_schema", None) + + caps_data = data.get("capabilities", {}) + capabilities = ChannelCapabilities( + media=caps_data.get("media", False), + group=caps_data.get("group", False), + dm=caps_data.get("dm", True), + streaming=caps_data.get("streaming", False), + typing=caps_data.get("typing", False), + reaction=caps_data.get("reaction", False), + thread=caps_data.get("thread", False), + max_text_length=caps_data.get("max_text_length", 4096), + ) + + manifest = PluginManifest( + id=plugin_id, + name=name, + version=version, + channel_type=channel_type, + capabilities=capabilities, + config_schema=config_schema, + entry=entry, + dependencies=dependencies, + ) + + return ManifestParseResult(manifest=manifest, raw=data) + except InvalidManifestException: + raise + except Exception as exc: + raise InvalidManifestException(str(exc)) from exc + + async def parse_file(self, path: str) -> ManifestParseResult: + try: + import yaml + + with open(path) as f: + data = yaml.safe_load(f) or {} + except FileNotFoundError as exc: + raise InvalidManifestException(f"manifest file not found: {path}") from exc + except Exception as exc: + raise InvalidManifestException(f"failed to parse manifest: {exc}") from exc + + return self.parse(data) diff --git a/backend/package/yuxi/channel/infrastructure/plugin/registry_impl.py b/backend/package/yuxi/channel/infrastructure/plugin/registry_impl.py new file mode 100644 index 00000000..fcd23f91 --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/registry_impl.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass + +from yuxi.channel.domain.exception.plugin_exception import PluginRegistrationException +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_registration_mode import PluginRegistrationMode +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort +from yuxi.channel.infrastructure.plugin.api_guard import ApiGuard +from yuxi.channel.infrastructure.plugin.discovery import PluginCandidate, PluginDiscovery +from yuxi.channel.infrastructure.plugin.loader import PluginLoader +from yuxi.channel.infrastructure.plugin.manifest_parser import ManifestParser + +logger = logging.getLogger(__name__) + +_registry_instance: PluginRegistryImpl | None = None + + +def get_registry() -> PluginRegistryImpl | None: + return _registry_instance + + +def set_registry(instance: PluginRegistryImpl | None) -> None: + global _registry_instance + _registry_instance = instance + + +@dataclass +class PluginRegistryImplConfig: + workspace_dir: str = "plugins" + installed_dir: str = "installed/plugins" + + +class PluginRegistryImpl: + def __init__( + self, + config: PluginRegistryImplConfig | None = None, + manifest_parser: ManifestParser | None = None, + loader: PluginLoader | None = None, + discovery: PluginDiscovery | None = None, + channel_config: object | None = None, + sse_endpoint: object | None = None, + ): + self._config = config or PluginRegistryImplConfig() + self._registry = PluginRegistry() + self._manifest_parser = manifest_parser or ManifestParser() + self._loader = loader or PluginLoader() + self._discovery = discovery or PluginDiscovery(self._manifest_parser) + self._api_guard = ApiGuard() + self._channel_config = channel_config + self._sse_endpoint = sse_endpoint + + set_registry(self) + + @property + def registry(self) -> PluginRegistry: + return self._registry + + async def register_plugin( + self, + candidate: PluginCandidate, + mode: PluginRegistrationMode = PluginRegistrationMode.FULL, + ) -> PluginRecord: + manifest = await self._manifest_parser.parse_file(candidate.manifest_path) if candidate.manifest_path else None + if manifest is None and candidate.manifest_data: + manifest = self._manifest_parser.parse(candidate.manifest_data) + + if manifest is None: + raise PluginRegistrationException(candidate.plugin_id) + + record = PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id=manifest.manifest.id, + name=manifest.manifest.name, + version=manifest.manifest.version, + channel_type=manifest.manifest.channel_type, + source=candidate.source, + manifest=manifest.manifest, + capabilities=manifest.manifest.capabilities, + config_schema=manifest.manifest.config_schema, + ) + + snapshot = self._registry.snapshot() + + try: + if candidate.source.entry_file: + module = await self._loader.load(candidate.source.entry_file) + adapter_cls = self._loader.extract_adapter_class(module) + if adapter_cls: + record.set_adapter_class(adapter_cls) + + api = self._api_guard.create(record.plugin_id) + await self._registry.register(record, api) + api.close() + + if mode == PluginRegistrationMode.FULL and candidate.source.enabled: + config = self._resolve_config(record) + record.set_config(config) + from yuxi.channel.domain.service.plugin_registry_domain_service import PluginRegistryDomainService + + PluginRegistryDomainService.transition_to_configured(record) + await self._registry.activate(record.plugin_id, **config) + + except Exception as exc: + self._registry.restore(snapshot) + record.mark_error(str(exc)) + raise PluginRegistrationException(record.plugin_id) from exc + + return record + + def register_builtin_adapter(self, name: str, adapter_cls: type[ChannelAdapterPort]) -> None: + existing = self._registry.get_record(name) + if existing: + return + + from yuxi.channel.domain.model.plugin_registry.plugin_registry_factory import PluginRegistryFactory + + caps = extract_capabilities(adapter_cls) + record = PluginRegistryFactory.create_builtin( + plugin_id=name, + name=name.capitalize(), + channel_type=name, + adapter_class=adapter_cls, + capabilities=caps, + ) + self._registry.register_builtin(record) + + def get_registered_adapter_classes(self) -> dict[str, type[ChannelAdapterPort]]: + result: dict[str, type[ChannelAdapterPort]] = {} + for record in self._registry.list_records(): + if record.adapter_class: + result[record.channel_type] = record.adapter_class + return result + + async def discover_and_register_all( + self, + mode: PluginRegistrationMode = PluginRegistrationMode.DISCOVERY, + ) -> list[PluginRecord]: + candidates = await self._discovery.discover_all() + records = [] + for candidate in candidates: + try: + record = await self.register_plugin(candidate, mode=mode) + records.append(record) + except Exception as exc: + logger.warning("failed to register plugin %s: %s", candidate.plugin_id, exc) + return records + + def resolve_config(self, record: PluginRecord) -> dict: + return self._resolve_config(record) + + def _resolve_config(self, record: PluginRecord) -> dict: + from yuxi.channel.channels._registry import get_channel_meta + + default_config = record.get_default_config() + yaml_override = {} + if self._channel_config and hasattr(self._channel_config, "get_channel_config"): + yaml_override = self._channel_config.get_channel_config(record.channel_type) + merged = {**default_config, **yaml_override} + + meta = get_channel_meta(record.channel_type) + + for dep in meta.get("infra_dependencies", []): + if dep == "sse_push" and self._sse_endpoint and "sse_push" not in merged: + merged["sse_push"] = self._sse_endpoint + if dep == "hook_mappings" and self._channel_config: + hook_mappings = getattr(self._channel_config, "hook_mappings", None) + if hook_mappings and "mappings" not in merged: + merged["mappings"] = hook_mappings + + env_mapping = meta.get("env_mapping", {}) + if env_mapping: + env_overrides = self._apply_env_overrides_generic(env_mapping) + merged.update(env_overrides) + + return merged + + @staticmethod + def _apply_env_overrides_generic(env_mapping: dict[str, str]) -> dict: + overrides: dict = {} + for env_var, config_key in env_mapping.items(): + value = os.getenv(env_var, "") + if value: + overrides[config_key] = value + return overrides + + +def extract_capabilities(adapter_cls: type) -> ChannelCapabilities: + from yuxi.channel.channels._registry import get_channel_meta + + meta = get_channel_meta(adapter_cls.__name__.lower().replace("adapter", "")) + config_cls = meta.get("config_cls") + if config_cls: + try: + instance = config_cls() + if hasattr(instance, "capabilities"): + return instance.capabilities + except Exception: + pass + try: + instance = adapter_cls.__new__(adapter_cls) + if hasattr(instance, "capabilities"): + caps = instance.capabilities + if isinstance(caps, ChannelCapabilities): + return caps + except Exception: + pass + return ChannelCapabilities() diff --git a/backend/package/yuxi/channel/infrastructure/plugin/snapshot.py b/backend/package/yuxi/channel/infrastructure/plugin/snapshot.py new file mode 100644 index 00000000..2a7eea5d --- /dev/null +++ b/backend/package/yuxi/channel/infrastructure/plugin/snapshot.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from yuxi.channel.domain.model.plugin_registry.plugin_registry import RegistrySnapshot + + +@dataclass +class SnapshotResult: + snapshot: RegistrySnapshot + plugin_count: int + adapter_count: int + + +def take_snapshot(registry: object) -> SnapshotResult: + from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry + + if not isinstance(registry, PluginRegistry): + raise TypeError("registry must be PluginRegistry") + + snap = registry.snapshot() + return SnapshotResult( + snapshot=snap, + plugin_count=len(snap.plugins), + adapter_count=len(snap.adapters), + ) + + +def restore_snapshot(registry: object, snapshot: RegistrySnapshot) -> None: + from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry + + if not isinstance(registry, PluginRegistry): + raise TypeError("registry must be PluginRegistry") + + registry.restore(snapshot) diff --git a/backend/package/yuxi/channel/interfaces/dto/request/plugin_request.py b/backend/package/yuxi/channel/interfaces/dto/request/plugin_request.py new file mode 100644 index 00000000..e97f35b9 --- /dev/null +++ b/backend/package/yuxi/channel/interfaces/dto/request/plugin_request.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ActivatePluginRequest(BaseModel): + config_override: dict = Field(default_factory=dict) + + +class ReloadPluginRequest(BaseModel): + force: bool = False diff --git a/backend/package/yuxi/channel/interfaces/dto/response/error_response.py b/backend/package/yuxi/channel/interfaces/dto/response/error_response.py new file mode 100644 index 00000000..f770ecae --- /dev/null +++ b/backend/package/yuxi/channel/interfaces/dto/response/error_response.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class ErrorResponse(BaseModel): + error: str + code: str | None = None + detail: str | None = None diff --git a/backend/package/yuxi/channel/interfaces/dto/response/plugin_response.py b/backend/package/yuxi/channel/interfaces/dto/response/plugin_response.py new file mode 100644 index 00000000..5ad9fc5b --- /dev/null +++ b/backend/package/yuxi/channel/interfaces/dto/response/plugin_response.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from pydantic import BaseModel + +from yuxi.channel.interfaces.rest.router.channel_status import ChannelCapabilitySchema + + +class PluginSummaryResponse(BaseModel): + plugin_id: str + name: str + version: str + channel_type: str + status: str + source: str + is_configured: bool + + +class PluginListResponse(BaseModel): + plugins: list[PluginSummaryResponse] + total: int + + +class PluginDetailResponse(BaseModel): + plugin_id: str + name: str + version: str + channel_type: str + status: str + source: str + is_configured: bool + capabilities: ChannelCapabilitySchema + config_schema: dict | None + registered_at: str | None + error: str | None + + +class ReloadResultResponse(BaseModel): + plugin_id: str + old_version: str + new_version: str + success: bool + + +class PluginManifestResponse(BaseModel): + id: str + name: str + version: str + channel_type: str + capabilities: ChannelCapabilitySchema + config_schema: dict | None = None + entry: str | None = None + dependencies: list[str] = [] + + +class PluginConfigSchemaResponse(BaseModel): + config_schema: dict | None = None diff --git a/backend/package/yuxi/channel/interfaces/rest/auth/depends.py b/backend/package/yuxi/channel/interfaces/rest/auth/depends.py index 2f4de164..1a963204 100644 --- a/backend/package/yuxi/channel/interfaces/rest/auth/depends.py +++ b/backend/package/yuxi/channel/interfaces/rest/auth/depends.py @@ -27,3 +27,23 @@ async def channel_auth_depends( raise HTTPException(status_code=401, detail=reason or "authorization required") return True + + +async def channel_auth_operator_depends( + authorization: str | None = Header(None), + token_query: str | None = Query(None, alias="token"), + channel: ChannelContainer = Depends(get_channel), +) -> str: + auth_value = authorization or (f"Bearer {token_query}" if token_query else None) + + passed, reason = await channel.auth_service.authenticate( + auth_value or "", + client_id="mgmt", + ) + + if not passed: + if "rate limited" in reason: + raise HTTPException(status_code=429, detail=reason) + raise HTTPException(status_code=401, detail=reason or "authorization required") + + return auth_value or "anonymous" diff --git a/backend/test/unit/channel/startup/__init__.py b/backend/package/yuxi/channel/interfaces/rest/dto/__init__.py similarity index 100% rename from backend/test/unit/channel/startup/__init__.py rename to backend/package/yuxi/channel/interfaces/rest/dto/__init__.py diff --git a/backend/package/yuxi/channel/interfaces/rest/dto/accepted_response.py b/backend/package/yuxi/channel/interfaces/rest/dto/accepted_response.py new file mode 100644 index 00000000..7abfdf4d --- /dev/null +++ b/backend/package/yuxi/channel/interfaces/rest/dto/accepted_response.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + + +class AcceptedResponse(BaseModel): + trace_id: str + status: str = "accepted" + message_id: str | None = None diff --git a/backend/package/yuxi/channel/interfaces/rest/dto/error_response.py b/backend/package/yuxi/channel/interfaces/rest/dto/error_response.py new file mode 100644 index 00000000..4ea75f8f --- /dev/null +++ b/backend/package/yuxi/channel/interfaces/rest/dto/error_response.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class ErrorResponse(BaseModel): + error: str + code: str + detail: dict | None = None + trace_id: str | None = None diff --git a/backend/package/yuxi/channel/interfaces/rest/dto/idempotency.py b/backend/package/yuxi/channel/interfaces/rest/dto/idempotency.py new file mode 100644 index 00000000..c5b387ee --- /dev/null +++ b/backend/package/yuxi/channel/interfaces/rest/dto/idempotency.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from uuid import uuid4 + + +def resolve_idempotency_key(raw: dict, headers: dict[str, str], channel_type: str) -> str: + header_key = headers.get("idempotency-key", "") + if header_key: + return header_key + if channel_type == "feishu": + return raw.get("header", {}).get("event_id", str(uuid4())) + if channel_type == "dingtalk": + return raw.get("msgId", str(uuid4())) + trace_id = raw.get("trace_id", headers.get("x-trace-id", "")) + return trace_id or str(uuid4()) diff --git a/backend/package/yuxi/channel/interfaces/rest/router/__init__.py b/backend/package/yuxi/channel/interfaces/rest/router/__init__.py index 2cd389db..2ad65515 100644 --- a/backend/package/yuxi/channel/interfaces/rest/router/__init__.py +++ b/backend/package/yuxi/channel/interfaces/rest/router/__init__.py @@ -3,6 +3,7 @@ from yuxi.channel.interfaces.rest.router.channel_status import router as channel from yuxi.channel.interfaces.rest.router.config_reload import router as config_reload_router from yuxi.channel.interfaces.rest.router.health import router as health_router from yuxi.channel.interfaces.rest.router.metrics import router as metrics_router +from yuxi.channel.interfaces.rest.router.plugin_registry import router as plugin_registry_router from yuxi.channel.interfaces.rest.router.registry import register_all_channel_routes from yuxi.channel.interfaces.rest.router.sse import router as sse_router @@ -12,6 +13,7 @@ __all__ = [ "config_reload_router", "health_router", "metrics_router", + "plugin_registry_router", "register_all_channel_routes", "sse_router", ] diff --git a/backend/package/yuxi/channel/interfaces/rest/router/bindings.py b/backend/package/yuxi/channel/interfaces/rest/router/bindings.py index 9887b1a8..754a574c 100644 --- a/backend/package/yuxi/channel/interfaces/rest/router/bindings.py +++ b/backend/package/yuxi/channel/interfaces/rest/router/bindings.py @@ -6,7 +6,9 @@ from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field from yuxi.channel.container import ChannelContainer, get_channel -from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends +from yuxi.channel.domain.exception.agent_config_not_found import AgentConfigNotFoundException +from yuxi.channel.domain.exception.duplicate_binding import DuplicateBindingException +from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends, channel_auth_operator_depends logger = logging.getLogger(__name__) @@ -20,6 +22,11 @@ class BindingCreateRequest(BaseModel): agent_config_id: int = Field(..., gt=0) +class BindingUpdateRequest(BaseModel): + agent_config_id: int | None = None + is_enabled: bool | None = None + + class BindingResponse(BaseModel): id: int channel_type: str @@ -27,7 +34,7 @@ class BindingResponse(BaseModel): group_id: str agent_config_id: int is_enabled: bool - created_at: str + created_at: str | None = None class PaginatedMeta(BaseModel): @@ -45,14 +52,20 @@ class BindingListResponse(BaseModel): async def create_binding( req: BindingCreateRequest, channel: ChannelContainer = Depends(get_channel), - _: bool = Depends(channel_auth_depends), + operator: str = Depends(channel_auth_operator_depends), ): - created = await channel.binding_service.create( - channel_type=req.channel_type, - account_id=req.account_id, - group_id=req.group_id, - agent_config_id=req.agent_config_id, - ) + try: + created = await channel.binding_service.create( + channel_type=req.channel_type, + account_id=req.account_id, + group_id=req.group_id, + agent_config_id=req.agent_config_id, + created_by=operator, + ) + except DuplicateBindingException as e: + raise HTTPException(status_code=409, detail=str(e)) + except AgentConfigNotFoundException as e: + raise HTTPException(status_code=400, detail=str(e)) return BindingResponse( id=created.id, @@ -61,7 +74,7 @@ async def create_binding( group_id=created.group_id, agent_config_id=created.agent_config_id, is_enabled=created.is_enabled, - created_at=created.created_at, + created_at=created.created_at.isoformat() if created.created_at else None, ) @@ -87,7 +100,7 @@ async def list_bindings( group_id=b.group_id, agent_config_id=b.agent_config_id, is_enabled=b.is_enabled, - created_at=b.created_at, + created_at=b.created_at.isoformat() if b.created_at else None, ) for b in bindings ], @@ -95,6 +108,31 @@ async def list_bindings( ) +@router.patch("/channel/bindings/{binding_id}", response_model=BindingResponse) +async def update_binding( + binding_id: int, + req: BindingUpdateRequest, + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + if req.agent_config_id is None and req.is_enabled is None: + raise HTTPException(status_code=400, detail="no fields to update") + updated = await channel.binding_service.update( + binding_id, agent_config_id=req.agent_config_id, is_enabled=req.is_enabled + ) + if not updated: + raise HTTPException(status_code=404, detail="binding not found") + return BindingResponse( + id=updated.id, + channel_type=updated.channel_type, + account_id=updated.account_id, + group_id=updated.group_id, + agent_config_id=updated.agent_config_id, + is_enabled=updated.is_enabled, + created_at=updated.created_at.isoformat() if updated.created_at else None, + ) + + @router.delete("/channel/bindings/{binding_id}") async def delete_binding( binding_id: int, diff --git a/backend/package/yuxi/channel/interfaces/rest/router/channel_status.py b/backend/package/yuxi/channel/interfaces/rest/router/channel_status.py index 279281d4..9856d059 100644 --- a/backend/package/yuxi/channel/interfaces/rest/router/channel_status.py +++ b/backend/package/yuxi/channel/interfaces/rest/router/channel_status.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import logging from fastapi import APIRouter, Depends @@ -19,6 +20,8 @@ class ChannelCapabilitySchema(BaseModel): dm: bool = True streaming: bool = False typing: bool = False + reaction: bool = False + thread: bool = False max_text_length: int = 4096 @@ -30,6 +33,49 @@ class ChannelStatusResponse(BaseModel): ws_connected: bool | None = None +async def _count_sessions(session_repo, name) -> int: + if not session_repo: + return 0 + try: + return await session_repo.count_active_sessions(name) + except Exception: + logger.debug("count_active_sessions failed for %s", name) + return 0 + + +async def _check_one_adapter( + name: str, + adapter, + session_repo, +) -> ChannelStatusResponse: + healthy, active_sessions = await asyncio.gather( + adapter.is_healthy(), + _count_sessions(session_repo, name), + return_exceptions=True, + ) + if isinstance(healthy, Exception): + healthy = False + if isinstance(active_sessions, Exception): + active_sessions = 0 + + caps = adapter.capabilities + return ChannelStatusResponse( + channel_type=name, + healthy=healthy, + capabilities=ChannelCapabilitySchema( + media=caps.media, + group=caps.group, + dm=caps.dm, + streaming=caps.streaming, + typing=caps.typing, + reaction=caps.reaction, + thread=caps.thread, + max_text_length=caps.max_text_length, + ), + active_sessions=active_sessions, + ) + + @router.get("/channel/status", response_model=list[ChannelStatusResponse]) async def get_channel_status( channel: ChannelContainer = Depends(get_channel), @@ -39,30 +85,12 @@ async def get_channel_status( if channel.ws_manager: ws_status = channel.ws_manager.connections_status - statuses = [] - for name, adapter in channel.adapters.items(): - healthy = await adapter.is_healthy() - caps = adapter.capabilities - active_sessions = 0 - if channel.session_repo: - try: - active_sessions = await channel.session_repo.count_active_sessions(name) - except Exception: - logger.debug("count_active_sessions failed for %s", name) - statuses.append( - ChannelStatusResponse( - channel_type=name, - healthy=healthy, - capabilities=ChannelCapabilitySchema( - media=caps.media, - group=caps.group, - dm=caps.dm, - streaming=caps.streaming, - typing=caps.typing, - max_text_length=caps.max_text_length, - ), - active_sessions=active_sessions, - ws_connected=ws_status.get(name), - ) - ) - return statuses + adapters = channel.get_adapters_snapshot() + statuses = await asyncio.gather( + *[_check_one_adapter(name, adapter, channel.session_repo) for name, adapter in adapters.items()] + ) + + for status in statuses: + status.ws_connected = ws_status.get(status.channel_type) + + return list(statuses) diff --git a/backend/package/yuxi/channel/domain/port/channel_route_contributor.py b/backend/package/yuxi/channel/interfaces/rest/router/contributor.py similarity index 100% rename from backend/package/yuxi/channel/domain/port/channel_route_contributor.py rename to backend/package/yuxi/channel/interfaces/rest/router/contributor.py diff --git a/backend/package/yuxi/channel/interfaces/rest/router/health.py b/backend/package/yuxi/channel/interfaces/rest/router/health.py index 3f4b70e0..27496c29 100644 --- a/backend/package/yuxi/channel/interfaces/rest/router/health.py +++ b/backend/package/yuxi/channel/interfaces/rest/router/health.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import logging +import os from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse @@ -36,24 +37,34 @@ class ReadyzResponse(BaseModel): @router.get("/healthz", response_model=HealthzResponse) async def liveness_check(channel: ChannelContainer = Depends(get_channel)): + show_timeline = os.getenv("CHANNEL_SHOW_STARTUP_TIMELINE", "false").lower() == "true" return HealthzResponse( status="ok", - startup_timeline=channel.startup_tracer.to_dict() if channel else None, + startup_timeline=channel.startup_tracer.to_dict() if show_timeline else None, ) @router.get("/readyz") async def readiness_check(channel: ChannelContainer = Depends(get_channel)): - checks = { - "redis": await _check_redis(channel), - "postgres": await _check_postgres(), - "workers": await _check_workers(channel), - "channels": await _check_channels(channel), - "ws_connections": await _check_ws_connections(channel), - } + keys = ["redis", "postgres", "workers", "channels", "ws_connections"] + results = await asyncio.gather( + _check_redis(channel), + _check_postgres(), + _check_workers(channel), + _check_channels(channel), + _check_ws_connections(channel), + ) + checks = dict(zip(keys, results)) - overall = "ready" if all(v.status == "ok" for v in checks.values()) else "not_ready" - status_code = 200 if overall == "ready" else 503 + has_error = any(v.status == "error" for v in checks.values()) + has_degraded = any(v.status == "degraded" for v in checks.values()) + + if has_error: + overall, status_code = "not_ready", 503 + elif has_degraded: + overall, status_code = "degraded", 200 + else: + overall, status_code = "ready", 200 return JSONResponse( status_code=status_code, @@ -75,7 +86,7 @@ async def _check_postgres() -> ReadyzComponentStatus: try: from yuxi.storage.postgres.manager import pg_manager - if not pg_manager._initialized: + if not pg_manager.is_initialized: return ReadyzComponentStatus(status="error", detail="not initialized") async with pg_manager.get_async_session_context() as session: await session.execute(select(1)) @@ -96,18 +107,20 @@ async def _check_workers(container: ChannelContainer) -> ReadyzComponentStatus: async def _check_channels(container: ChannelContainer) -> ReadyzComponentStatus: try: - if container and container.adapters: - results = await asyncio.gather( - *[a.is_healthy() for a in container.adapters.values()], - return_exceptions=True, - ) - unhealthy = [name for name, r in zip(container.adapters, results) if r is not True] - if not unhealthy: - return ReadyzComponentStatus(status="ok") - return ReadyzComponentStatus( - status="degraded", - detail=f"unhealthy channels: {', '.join(unhealthy)}", - ) + adapters = container.get_adapters_snapshot() if container else {} + if not adapters: + return ReadyzComponentStatus(status="error", detail="no adapters") + results = await asyncio.gather( + *[a.is_healthy() for a in adapters.values()], + return_exceptions=True, + ) + unhealthy = [name for name, r in zip(adapters, results) if r is not True] + if not unhealthy: + return ReadyzComponentStatus(status="ok") + return ReadyzComponentStatus( + status="degraded", + detail=f"unhealthy channels: {', '.join(unhealthy)}", + ) except Exception as exc: logger.warning("channels check failed: %s", exc) return ReadyzComponentStatus(status="error", detail="no adapters") diff --git a/backend/package/yuxi/channel/interfaces/rest/router/metrics.py b/backend/package/yuxi/channel/interfaces/rest/router/metrics.py index 76922a5a..8c8505ae 100644 --- a/backend/package/yuxi/channel/interfaces/rest/router/metrics.py +++ b/backend/package/yuxi/channel/interfaces/rest/router/metrics.py @@ -7,6 +7,7 @@ from fastapi.responses import Response from prometheus_client import CONTENT_TYPE_LATEST, generate_latest from yuxi.channel.container import ChannelContainer, get_channel +from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends logger = logging.getLogger(__name__) @@ -14,8 +15,11 @@ router = APIRouter() @router.get("/metrics") -async def metrics(channel: ChannelContainer = Depends(get_channel)): - if channel and channel.sse_endpoint and channel.metrics: +async def metrics( + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + if channel.sse_endpoint and channel.metrics: await channel.metrics.set_sse_connections(channel.sse_endpoint.connection_count) data = generate_latest() diff --git a/backend/package/yuxi/channel/interfaces/rest/router/plugin_registry.py b/backend/package/yuxi/channel/interfaces/rest/router/plugin_registry.py new file mode 100644 index 00000000..4f1be021 --- /dev/null +++ b/backend/package/yuxi/channel/interfaces/rest/router/plugin_registry.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import logging +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException + +from yuxi.channel.application.command.plugin.activate_command import ActivateCommand +from yuxi.channel.application.command.plugin.deactivate_command import DeactivateCommand +from yuxi.channel.application.command.plugin.reload_command import ReloadCommand +from yuxi.channel.application.service.plugin_registry_app_service import PluginRegistryAppService +from yuxi.channel.container import ChannelContainer, get_channel +from yuxi.channel.domain.exception.plugin_exception import ( + ActiveSessionExistsException, + InvalidPluginStateException, + PluginAlreadyActivatedException, + PluginNotConfiguredException, + PluginNotFoundException, + PluginReloadException, +) +from yuxi.channel.interfaces.dto.request.plugin_request import ActivatePluginRequest, ReloadPluginRequest +from yuxi.channel.interfaces.dto.response.plugin_response import ( + PluginConfigSchemaResponse, + PluginDetailResponse, + PluginListResponse, + PluginManifestResponse, + PluginSummaryResponse, + ReloadResultResponse, +) +from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends +from yuxi.channel.interfaces.rest.router.channel_status import ChannelCapabilitySchema + +logger = logging.getLogger(__name__) + +router = APIRouter() + +ValidPluginStatus = Literal["discovered", "registered", "configured", "active", "error", "disabled"] + + +def _get_app_service(channel: ChannelContainer) -> PluginRegistryAppService: + if not channel.plugin_app_service: + raise HTTPException(status_code=503, detail="plugin registry not initialized") + return channel.plugin_app_service + + +@router.get("/channel/plugins", response_model=PluginListResponse) +async def list_plugins( + origin: str | None = None, + status: ValidPluginStatus | None = None, + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + svc = _get_app_service(channel) + result = svc.list_plugins(origin=origin, status=status) + return PluginListResponse( + plugins=[PluginSummaryResponse(**p.model_dump()) for p in result.plugins], + total=result.total, + ) + + +@router.get("/channel/plugins/{plugin_id}", response_model=PluginDetailResponse) +async def get_plugin( + plugin_id: str, + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + svc = _get_app_service(channel) + try: + detail = svc.get_plugin(plugin_id) + except PluginNotFoundException: + raise HTTPException(status_code=404, detail=f"plugin not found: {plugin_id}") + return PluginDetailResponse( + plugin_id=detail.plugin_id, + name=detail.name, + version=detail.version, + channel_type=detail.channel_type, + status=detail.status, + source=detail.source, + is_configured=detail.is_configured, + capabilities=ChannelCapabilitySchema(**detail.capabilities), + config_schema=detail.config_schema, + registered_at=detail.registered_at, + error=detail.error, + ) + + +@router.post("/channel/plugins/{plugin_id}/activate", response_model=PluginDetailResponse) +async def activate_plugin( + plugin_id: str, + body: ActivatePluginRequest = ActivatePluginRequest(), + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + svc = _get_app_service(channel) + try: + result = await svc.activate(ActivateCommand(plugin_id=plugin_id, config_override=body.config_override)) + except PluginNotFoundException: + raise HTTPException(status_code=404, detail=f"plugin not found: {plugin_id}") + except PluginAlreadyActivatedException: + raise HTTPException(status_code=409, detail=f"plugin already activated: {plugin_id}") + except PluginNotConfiguredException: + raise HTTPException(status_code=400, detail=f"plugin not configured: {plugin_id}") + except InvalidPluginStateException as e: + raise HTTPException(status_code=409, detail=str(e)) + except Exception as exc: + logger.error("activate plugin %s failed: %s", plugin_id, exc, exc_info=True) + raise HTTPException(status_code=500, detail="activation failed") + + return PluginDetailResponse( + plugin_id=result.plugin_id, + name=result.name, + version=result.version, + channel_type=result.channel_type, + status=result.status, + source=result.source, + is_configured=result.is_configured, + capabilities=ChannelCapabilitySchema(**result.capabilities), + config_schema=result.config_schema, + registered_at=result.registered_at, + error=result.error, + ) + + +@router.post("/channel/plugins/{plugin_id}/deactivate", response_model=PluginDetailResponse) +async def deactivate_plugin( + plugin_id: str, + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + svc = _get_app_service(channel) + try: + result = await svc.deactivate( + DeactivateCommand(plugin_id=plugin_id), + session_repo=channel.session_repo, + ) + except PluginNotFoundException: + raise HTTPException(status_code=404, detail=f"plugin not found: {plugin_id}") + except InvalidPluginStateException as e: + raise HTTPException(status_code=409, detail=str(e)) + except ActiveSessionExistsException as e: + raise HTTPException(status_code=409, detail=str(e)) + except Exception as exc: + logger.error("deactivate plugin %s failed: %s", plugin_id, exc, exc_info=True) + raise HTTPException(status_code=500, detail="deactivation failed") + + return PluginDetailResponse( + plugin_id=result.plugin_id, + name=result.name, + version=result.version, + channel_type=result.channel_type, + status=result.status, + source=result.source, + is_configured=result.is_configured, + capabilities=ChannelCapabilitySchema(**result.capabilities), + config_schema=result.config_schema, + registered_at=result.registered_at, + error=result.error, + ) + + +@router.post("/channel/plugins/{plugin_id}/reload", response_model=ReloadResultResponse) +async def reload_plugin( + plugin_id: str, + body: ReloadPluginRequest = ReloadPluginRequest(), + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + svc = _get_app_service(channel) + try: + result = await svc.reload_plugin(ReloadCommand(plugin_id=plugin_id, force=body.force)) + except PluginNotFoundException: + raise HTTPException(status_code=404, detail=f"plugin not found: {plugin_id}") + except PluginReloadException: + raise HTTPException(status_code=500, detail=f"plugin reload failed: {plugin_id}") + except InvalidPluginStateException as e: + raise HTTPException(status_code=409, detail=str(e)) + except Exception as exc: + logger.error("reload plugin %s failed: %s", plugin_id, exc, exc_info=True) + raise HTTPException(status_code=500, detail="reload failed") + + return ReloadResultResponse(**result.model_dump()) + + +@router.get("/channel/plugins/{plugin_id}/manifest", response_model=PluginManifestResponse) +async def get_plugin_manifest( + plugin_id: str, + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + svc = _get_app_service(channel) + try: + manifest = svc.get_manifest(plugin_id) + except PluginNotFoundException: + raise HTTPException(status_code=404, detail=f"plugin not found: {plugin_id}") + return PluginManifestResponse( + id=manifest["id"], + name=manifest["name"], + version=manifest["version"], + channel_type=manifest["channel_type"], + capabilities=ChannelCapabilitySchema(**manifest["capabilities"]), + config_schema=manifest.get("config_schema"), + entry=manifest.get("entry"), + dependencies=manifest.get("dependencies", []), + ) + + +@router.get("/channel/plugins/{plugin_id}/config-schema", response_model=PluginConfigSchemaResponse) +async def get_plugin_config_schema( + plugin_id: str, + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + svc = _get_app_service(channel) + try: + schema = svc.get_config_schema(plugin_id) + except PluginNotFoundException: + raise HTTPException(status_code=404, detail=f"plugin not found: {plugin_id}") + return PluginConfigSchemaResponse(config_schema=schema) diff --git a/backend/package/yuxi/channel/interfaces/rest/router/registry.py b/backend/package/yuxi/channel/interfaces/rest/router/registry.py index 13c3c03f..9a71f197 100644 --- a/backend/package/yuxi/channel/interfaces/rest/router/registry.py +++ b/backend/package/yuxi/channel/interfaces/rest/router/registry.py @@ -5,7 +5,7 @@ import logging from fastapi import FastAPI from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort -from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor +from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor logger = logging.getLogger(__name__) diff --git a/backend/package/yuxi/channel/interfaces/rest/router/sse.py b/backend/package/yuxi/channel/interfaces/rest/router/sse.py index 511d4b3b..f5f3d704 100644 --- a/backend/package/yuxi/channel/interfaces/rest/router/sse.py +++ b/backend/package/yuxi/channel/interfaces/rest/router/sse.py @@ -2,8 +2,10 @@ from __future__ import annotations import logging import re +from uuid import uuid4 -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel, Field from yuxi.channel.container import ChannelContainer, get_channel from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends @@ -14,22 +16,88 @@ router = APIRouter() _SESSION_ID_PATTERN = re.compile(r"^[a-zA-Z0-9\-_]{1,128}$") +_REDEEM_SCRIPT = """ +local v = redis.call('GET', KEYS[1]) +if v then + redis.call('DEL', KEYS[1]) +end +return v +""" + + +class SseTicketRequest(BaseModel): + session_id: str = Field(..., min_length=1, max_length=128) + + +class SseTicketResponse(BaseModel): + ticket: str + expires_in: int = 30 + + +async def _redeem_ticket(channel: ChannelContainer, ticket: str) -> str | None: + if not channel.cache_port: + return None + cache_key = f"sse:ticket:{ticket}" + try: + result = await channel.cache_port.eval( + _REDEEM_SCRIPT, + keys=[cache_key], + args=[], + ) + if isinstance(result, str): + return result + if isinstance(result, (list, tuple)) and len(result) > 0: + return result[0] if isinstance(result[0], str) else None + return None + except Exception: + session_id = await channel.cache_port.get(cache_key) + if session_id: + await channel.cache_port.delete(cache_key) + return session_id + + +@router.post("/channel/sse/ticket", response_model=SseTicketResponse) +async def create_sse_ticket( + req: SseTicketRequest, + channel: ChannelContainer = Depends(get_channel), + _: bool = Depends(channel_auth_depends), +): + ticket = uuid4().hex + if channel.cache_port: + await channel.cache_port.set( + f"sse:ticket:{ticket}", + req.session_id, + ex=30, + ) + return SseTicketResponse(ticket=ticket, expires_in=30) + @router.get("/channel/sse") async def subscribe_sse( request: Request, - session_id: str = Query(..., min_length=1), + ticket: str | None = Query(None, min_length=1, max_length=64), + token: str | None = Query(None, alias="token", min_length=1), + session_id: str | None = Query(None, min_length=1), channel: ChannelContainer = Depends(get_channel), - _: bool = Depends(channel_auth_depends), ): - if not _SESSION_ID_PATTERN.match(session_id): - from fastapi import HTTPException + resolved_session_id: str | None = None + if ticket: + resolved_session_id = await _redeem_ticket(channel, ticket) + if not resolved_session_id: + raise HTTPException(status_code=401, detail="invalid or expired ticket") + elif token: + passed, reason = await channel.auth_service.authenticate(f"Bearer {token}", client_id="mgmt") + if not passed: + if "rate limited" in reason: + raise HTTPException(status_code=429, detail=reason) + raise HTTPException(status_code=401, detail=reason or "authorization required") + resolved_session_id = session_id + else: + raise HTTPException(status_code=401, detail="ticket or token required") + + if not resolved_session_id or not _SESSION_ID_PATTERN.match(resolved_session_id): raise HTTPException(status_code=400, detail="invalid session_id format") - if not channel.sse_endpoint: - from fastapi import HTTPException - - raise HTTPException(status_code=503, detail="sse endpoint not initialized") - - return await channel.sse_endpoint.subscribe(session_id, request) + sse_endpoint = channel.require("sse_endpoint") + return await sse_endpoint.subscribe(resolved_session_id, request) diff --git a/backend/package/yuxi/channel/interfaces/sse/endpoint.py b/backend/package/yuxi/channel/interfaces/sse/endpoint.py index 12dbe770..d2a5b934 100644 --- a/backend/package/yuxi/channel/interfaces/sse/endpoint.py +++ b/backend/package/yuxi/channel/interfaces/sse/endpoint.py @@ -25,6 +25,7 @@ class _SseConnection: class SseEndpoint(SsePushPort): STALE_THRESHOLD_SECONDS = 300 CLEANUP_INTERVAL_SECONDS = 60 + MAX_CONNECTIONS_PER_SESSION = 5 def __init__(self, *, max_connections: int = 1000, metrics: MetricsPort | None = None) -> None: self._connections: dict[str, list[_SseConnection]] = {} @@ -63,6 +64,10 @@ class SseEndpoint(SsePushPort): if self.connection_count >= self._max_connections: raise HTTPException(status_code=503, detail="SSE connection limit reached") + session_conns = self._connections.get(session_id, []) + if len(session_conns) >= self.MAX_CONNECTIONS_PER_SESSION: + raise HTTPException(status_code=429, detail="too many SSE connections for this session") + now = time.monotonic() queue: asyncio.Queue = asyncio.Queue(maxsize=256) conn = _SseConnection(session_id=session_id, queue=queue, last_active_at=now) @@ -71,7 +76,8 @@ class SseEndpoint(SsePushPort): async def event_stream(): try: - yield f'event: connected\ndata: {{"session_id": "{session_id}"}}\n\n' + connected_data = json.dumps({"session_id": session_id}, ensure_ascii=False) + yield f"event: connected\ndata: {connected_data}\n\n" while True: if await request.is_disconnected(): break diff --git a/backend/package/yuxi/channel/interfaces/websocket/manager.py b/backend/package/yuxi/channel/interfaces/websocket/manager.py index 29b15a30..2e34ff5f 100644 --- a/backend/package/yuxi/channel/interfaces/websocket/manager.py +++ b/backend/package/yuxi/channel/interfaces/websocket/manager.py @@ -5,6 +5,7 @@ import logging from uuid import uuid4 from yuxi.channel.application.service.inbound_service import InboundService +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort from yuxi.channel.domain.port.metrics_port import MetricsPort from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort @@ -13,9 +14,10 @@ logger = logging.getLogger(__name__) class WsConnectionManager: - def __init__(self, metrics: MetricsPort | None = None) -> None: + def __init__(self, metrics: MetricsPort | None = None, *, registry: PluginRegistry | None = None) -> None: self._connections: dict[str, WsConnectionPort] = {} self._adapters: dict[str, ChannelAdapterPort] = {} + self._registry = registry self._loop: asyncio.AbstractEventLoop | None = None self._inbound_service: InboundService | None = None self._metrics = metrics @@ -26,6 +28,9 @@ class WsConnectionManager: def set_adapters(self, adapters: dict[str, ChannelAdapterPort]) -> None: self._adapters = adapters + def set_registry(self, registry: PluginRegistry) -> None: + self._registry = registry + async def start_all( self, loop: asyncio.AbstractEventLoop, @@ -48,7 +53,7 @@ class WsConnectionManager: async def _on_message(self, raw: dict) -> None: channel_type = raw.get("channel_type", "") - adapter = self._adapters.get(channel_type) + adapter = self._registry.get_adapter(channel_type) if self._registry else self._adapters.get(channel_type) if not adapter: logger.warning("no adapter for ws message from %s", channel_type) return diff --git a/backend/package/yuxi/channel/startup.py b/backend/package/yuxi/channel/startup.py index 8d3f3bb9..a2052086 100644 --- a/backend/package/yuxi/channel/startup.py +++ b/backend/package/yuxi/channel/startup.py @@ -18,8 +18,26 @@ async def init_channel( mq_workers: int = 4, mq_max_concurrent: int = 20, default_agent_config_id: int = 1, + use_plugin_registry: bool = True, ) -> ChannelContainer: global _container + + plugin_registry_impl = None + if use_plugin_registry: + try: + from yuxi.channel.infrastructure.plugin.registry_impl import PluginRegistryImpl + + plugin_registry_impl = PluginRegistryImpl() + + import yuxi.channel.channels # noqa: F401 + + from yuxi.channel.infrastructure.plugin.bundled.register_builtin import register_builtin_plugins + + await register_builtin_plugins(plugin_registry_impl.registry) + await plugin_registry_impl.discover_and_register_all() + except ImportError: + pass + _container = await ChannelContainerFactory.create( redis_url=redis_url, agent_port=agent_port, @@ -27,6 +45,7 @@ async def init_channel( mq_workers=mq_workers, mq_max_concurrent=mq_max_concurrent, default_agent_config_id=default_agent_config_id, + plugin_registry_impl=plugin_registry_impl, ) return _container @@ -46,3 +65,9 @@ def get_container() -> ChannelContainer | None: def register_channel_middleware(app) -> None: if _container: setup_channel(app, _container) + from yuxi.channel.interfaces.rest.router.registry import register_all_channel_routes + + registered = register_all_channel_routes(app, _container.adapters) + import logging + + logging.getLogger(__name__).info("registered channel routes: %s", registered) diff --git a/backend/package/yuxi/channel/worker/outbox_retry.py b/backend/package/yuxi/channel/worker/outbox_retry.py index cb19346f..85966928 100644 --- a/backend/package/yuxi/channel/worker/outbox_retry.py +++ b/backend/package/yuxi/channel/worker/outbox_retry.py @@ -6,6 +6,8 @@ import logging import redis.asyncio as aioredis from yuxi.channel.domain.model.message.dispatch_result import SendResult +from yuxi.channel.domain.model.outbox.outbox_status import OutboxStatus +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort from yuxi.channel.domain.port.metrics_port import MetricsPort from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort @@ -24,9 +26,11 @@ class OutboxRetryWorker: *, metrics: MetricsPort | None = None, poll_interval: float = 5.0, + registry: PluginRegistry | None = None, ) -> None: self._outbox = outbox_repo self._adapters = adapters + self._registry = registry self._redis = redis self._metrics = metrics self._poll_interval = poll_interval @@ -96,9 +100,13 @@ class OutboxRetryWorker: pass async def _process_pending(self) -> None: - entries = await self._outbox.fetch_pending() + entries = await self._outbox.fetch_and_lock() for entry in entries: - adapter = self._adapters.get(entry.channel_type) + adapter = ( + self._registry.get_adapter(entry.channel_type) + if self._registry + else self._adapters.get(entry.channel_type) + ) if not adapter: continue @@ -114,11 +122,9 @@ class OutboxRetryWorker: if self._metrics: await self._metrics.record_outbox_retry_total(entry.channel_type, "success") else: - if entry.retry_count + 1 >= entry.max_retries: - await self._outbox.mark_dead(entry.id, last_error=result.error or "max_retries_exceeded") + updated = await self._outbox.mark_retrying(entry.id, last_error=result.error) + if updated and updated.status == OutboxStatus.DEAD: if self._metrics: await self._metrics.record_outbox_retry_total(entry.channel_type, "dead") - else: - await self._outbox.mark_retrying(entry.id, last_error=result.error) - if self._metrics: - await self._metrics.record_outbox_retry_total(entry.channel_type, "retrying") + elif self._metrics: + await self._metrics.record_outbox_retry_total(entry.channel_type, "retrying") diff --git a/backend/package/yuxi/storage/postgres/manager.py b/backend/package/yuxi/storage/postgres/manager.py index 726c3a74..f2c85081 100644 --- a/backend/package/yuxi/storage/postgres/manager.py +++ b/backend/package/yuxi/storage/postgres/manager.py @@ -37,6 +37,10 @@ class PostgresManager(metaclass=SingletonMeta): self.langgraph_pool = None self._initialized = False + @property + def is_initialized(self) -> bool: + return self._initialized + def initialize(self): """初始化数据库连接""" if self._initialized: @@ -355,6 +359,88 @@ class PostgresManager(metaclass=SingletonMeta): "ON channel_message_logs(agent_config_id)", "CREATE INDEX IF NOT EXISTS ix_channel_message_logs_status ON channel_message_logs(status)", "CREATE INDEX IF NOT EXISTS ix_channel_message_logs_is_deleted ON channel_message_logs(is_deleted)", + # channel_plugins 表 + """ + CREATE TABLE IF NOT EXISTS channel_plugins ( + id SERIAL PRIMARY KEY, + plugin_id VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + version VARCHAR(32) NOT NULL DEFAULT '0.0.0', + channel_type VARCHAR(64) NOT NULL, + origin VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'discovered', + root_dir VARCHAR(512), + entry_file VARCHAR(512), + manifest JSONB, + capabilities JSONB, + config_schema JSONB, + config JSONB DEFAULT '{}'::jsonb, + error TEXT, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + registered_at TIMESTAMPTZ, + activated_at TIMESTAMPTZ, + created_by VARCHAR(64), + updated_by VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + is_deleted INTEGER NOT NULL DEFAULT 0, + deleted_at TIMESTAMPTZ + ) + """, + "CREATE INDEX IF NOT EXISTS ix_channel_plugins_plugin_id ON channel_plugins(plugin_id)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugins_channel_type ON channel_plugins(channel_type)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugins_status ON channel_plugins(status)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugins_origin ON channel_plugins(origin)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugins_is_enabled ON channel_plugins(is_enabled)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugins_is_deleted ON channel_plugins(is_deleted)", + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_channel_plugin_channel_type_active + ON channel_plugins (channel_type) + WHERE is_deleted = 0 + """, + # channel_plugin_config_history 表 + """ + CREATE TABLE IF NOT EXISTS channel_plugin_config_history ( + id SERIAL PRIMARY KEY, + plugin_id VARCHAR(64) NOT NULL, + config JSONB NOT NULL, + changed_by VARCHAR(128), + change_reason VARCHAR(255), + created_by VARCHAR(64), + updated_by VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + is_deleted INTEGER NOT NULL DEFAULT 0, + deleted_at TIMESTAMPTZ + ) + """, + "CREATE INDEX IF NOT EXISTS ix_channel_plugin_config_history_plugin_id ON channel_plugin_config_history(plugin_id)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugin_config_history_created_at ON channel_plugin_config_history(created_at)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugin_config_history_is_deleted ON channel_plugin_config_history(is_deleted)", + # channel_plugin_audit_logs 表 + """ + CREATE TABLE IF NOT EXISTS channel_plugin_audit_logs ( + id SERIAL PRIMARY KEY, + plugin_id VARCHAR(64) NOT NULL, + action VARCHAR(32) NOT NULL, + old_status VARCHAR(32), + new_status VARCHAR(32), + old_version VARCHAR(32), + new_version VARCHAR(32), + detail JSONB, + error_message TEXT, + created_by VARCHAR(64), + updated_by VARCHAR(64), + created_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + updated_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() AT TIME ZONE 'UTC'), + is_deleted INTEGER NOT NULL DEFAULT 0, + deleted_at TIMESTAMPTZ + ) + """, + "CREATE INDEX IF NOT EXISTS ix_channel_plugin_audit_logs_plugin_id ON channel_plugin_audit_logs(plugin_id)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugin_audit_logs_action ON channel_plugin_audit_logs(action)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugin_audit_logs_created_at ON channel_plugin_audit_logs(created_at)", + "CREATE INDEX IF NOT EXISTS ix_channel_plugin_audit_logs_is_deleted ON channel_plugin_audit_logs(is_deleted)", ] async with self.async_engine.begin() as conn: for stmt in stmts: diff --git a/backend/package/yuxi/storage/postgres/models_channel.py b/backend/package/yuxi/storage/postgres/models_channel.py index 450ffd46..6d5cf9b1 100644 --- a/backend/package/yuxi/storage/postgres/models_channel.py +++ b/backend/package/yuxi/storage/postgres/models_channel.py @@ -1,4 +1,4 @@ -"""PostgreSQL 渠道网关模型 - 渠道绑定、出站重试、消息审计日志""" +"""PostgreSQL 渠道网关模型 - 渠道绑定、出站重试、消息审计日志、插件注册""" from sqlalchemy import ( JSON, @@ -164,3 +164,150 @@ class ChannelMessageLog(Base): "is_deleted": self.is_deleted, "deleted_at": format_utc_datetime(self.deleted_at), } + + +class ChannelPlugin(Base): + """渠道插件注册表 — 存储插件元数据、状态与配置 + + 对应领域模型:PluginRecord 实体 + 覆盖来源:workspace(本地开发) + installed(第三方安装) + bundled(内置) 插件不入库,由代码自动注册 + """ + + __tablename__ = "channel_plugins" + + id = Column(Integer, primary_key=True, autoincrement=True, comment="主键") + plugin_id = Column(String(64), nullable=False, unique=True, index=True, comment="插件唯一标识") + name = Column(String(128), nullable=False, comment="插件显示名称") + version = Column(String(32), nullable=False, default="0.0.0", comment="插件版本号") + channel_type = Column(String(64), nullable=False, index=True, comment="渠道类型标识") + origin = Column(String(32), nullable=False, comment="来源: bundled/workspace/installed") + status = Column(String(32), nullable=False, default="discovered", comment="状态: discovered/registered/configured/active/error/disabled") + root_dir = Column(String(512), nullable=True, comment="插件根目录路径") + entry_file = Column(String(512), nullable=True, comment="入口文件路径") + manifest = Column(JSON, nullable=True, comment="完整插件清单(yuxi.plugin.yaml内容)") + capabilities = Column(JSON, nullable=True, comment="渠道能力声明") + config_schema = Column(JSON, nullable=True, comment="配置校验schema") + config = Column(JSON, nullable=True, default=dict, comment="当前生效配置") + error = Column(Text, nullable=True, comment="错误信息") + is_enabled = Column(Boolean, nullable=False, default=True, index=True, comment="是否启用") + registered_at = Column(DateTime, nullable=True, comment="注册时间") + activated_at = Column(DateTime, nullable=True, comment="最后激活时间") + + created_by = Column(String(64), nullable=True) + updated_by = Column(String(64), nullable=True) + created_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="创建时间") + updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive, comment="更新时间") + is_deleted = Column(Integer, nullable=False, default=0, index=True, comment="软删除标记: 0=否, 1=是") + deleted_at = Column(DateTime, nullable=True, comment="删除时间") + + def to_dict(self): + return { + "id": self.id, + "plugin_id": self.plugin_id, + "name": self.name, + "version": self.version, + "channel_type": self.channel_type, + "origin": self.origin, + "status": self.status, + "root_dir": self.root_dir, + "entry_file": self.entry_file, + "manifest": self.manifest or {}, + "capabilities": self.capabilities or {}, + "config_schema": self.config_schema or {}, + "config": self.config or {}, + "error": self.error, + "is_enabled": bool(self.is_enabled), + "registered_at": format_utc_datetime(self.registered_at), + "activated_at": format_utc_datetime(self.activated_at), + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + "is_deleted": self.is_deleted, + "deleted_at": format_utc_datetime(self.deleted_at), + } + + +class ChannelPluginConfigHistory(Base): + """插件配置历史表 — 记录配置变更,支持回滚 + + 对应领域事件:PluginConfiguredEvent + 生命周期:每次调用 set_config() 时写入一条记录 + """ + + __tablename__ = "channel_plugin_config_history" + + id = Column(Integer, primary_key=True, autoincrement=True, comment="主键") + plugin_id = Column(String(64), nullable=False, index=True, comment="插件标识") + config = Column(JSON, nullable=False, comment="配置快照") + changed_by = Column(String(128), nullable=True, comment="变更人") + change_reason = Column(String(255), nullable=True, comment="变更原因") + + created_by = Column(String(64), nullable=True) + updated_by = Column(String(64), nullable=True) + created_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="创建时间") + updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive, comment="更新时间") + is_deleted = Column(Integer, nullable=False, default=0, index=True, comment="软删除标记: 0=否, 1=是") + deleted_at = Column(DateTime, nullable=True, comment="删除时间") + + def to_dict(self): + return { + "id": self.id, + "plugin_id": self.plugin_id, + "config": self.config or {}, + "changed_by": self.changed_by, + "change_reason": self.change_reason, + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + "is_deleted": self.is_deleted, + "deleted_at": format_utc_datetime(self.deleted_at), + } + + +class ChannelPluginAuditLog(Base): + """插件操作审计日志 — 记录插件生命周期关键操作 + + 对应领域事件:PluginRegisteredEvent / PluginActivatedEvent / + PluginDeactivatedEvent / PluginReloadEvent + """ + + __tablename__ = "channel_plugin_audit_logs" + + id = Column(Integer, primary_key=True, autoincrement=True, comment="主键") + plugin_id = Column(String(64), nullable=False, index=True, comment="插件标识") + action = Column(String(32), nullable=False, comment="操作: register/activate/deactivate/reload/error") + old_status = Column(String(32), nullable=True, comment="操作前状态") + new_status = Column(String(32), nullable=True, comment="操作后状态") + old_version = Column(String(32), nullable=True, comment="操作前版本(重载场景)") + new_version = Column(String(32), nullable=True, comment="操作后版本(重载场景)") + detail = Column(JSON, nullable=True, comment="操作详情") + error_message = Column(Text, nullable=True, comment="错误信息") + + created_by = Column(String(64), nullable=True) + updated_by = Column(String(64), nullable=True) + created_at = Column(DateTime, nullable=False, default=utc_now_naive, comment="创建时间") + updated_at = Column(DateTime, nullable=False, default=utc_now_naive, onupdate=utc_now_naive, comment="更新时间") + is_deleted = Column(Integer, nullable=False, default=0, index=True, comment="软删除标记: 0=否, 1=是") + deleted_at = Column(DateTime, nullable=True, comment="删除时间") + + def to_dict(self): + return { + "id": self.id, + "plugin_id": self.plugin_id, + "action": self.action, + "old_status": self.old_status, + "new_status": self.new_status, + "old_version": self.old_version, + "new_version": self.new_version, + "detail": self.detail or {}, + "error_message": self.error_message, + "created_by": self.created_by, + "updated_by": self.updated_by, + "created_at": format_utc_datetime(self.created_at), + "updated_at": format_utc_datetime(self.updated_at), + "is_deleted": self.is_deleted, + "deleted_at": format_utc_datetime(self.deleted_at), + } diff --git a/backend/server/routers/channel_router.py b/backend/server/routers/channel_router.py index 2bdf26a8..0b735b07 100644 --- a/backend/server/routers/channel_router.py +++ b/backend/server/routers/channel_router.py @@ -4,14 +4,12 @@ import logging from fastapi import APIRouter -from yuxi.channel.channels.feishu.routes import router as feishu_event_router -from yuxi.channel.channels.web.routes import router as web_message_router -from yuxi.channel.channels.hooks.routes import router as hooks_message_router from yuxi.channel.interfaces.rest.router.bindings import router as bindings_router from yuxi.channel.interfaces.rest.router.channel_status import router as channel_status_router from yuxi.channel.interfaces.rest.router.config_reload import router as config_reload_router from yuxi.channel.interfaces.rest.router.health import router as health_router from yuxi.channel.interfaces.rest.router.metrics import router as metrics_router +from yuxi.channel.interfaces.rest.router.plugin_registry import router as plugin_registry_router from yuxi.channel.interfaces.rest.router.sse import router as sse_router logger = logging.getLogger(__name__) @@ -24,6 +22,4 @@ channel.include_router(channel_status_router, tags=["channel-status"]) channel.include_router(bindings_router, tags=["channel-bindings"]) channel.include_router(sse_router, tags=["channel-sse"]) channel.include_router(config_reload_router, tags=["channel-config"]) -channel.include_router(feishu_event_router, tags=["channel-feishu-event"]) -channel.include_router(web_message_router, tags=["channel-web-message"]) -channel.include_router(hooks_message_router, tags=["channel-hooks-message"]) +channel.include_router(plugin_registry_router, tags=["channel-plugins"]) diff --git a/backend/server/utils/lifespan.py b/backend/server/utils/lifespan.py index 10380f25..ab2fcbab 100644 --- a/backend/server/utils/lifespan.py +++ b/backend/server/utils/lifespan.py @@ -3,17 +3,16 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver - -from yuxi.services.task_service import tasker +from yuxi import get_version +from yuxi.agents.backends.sandbox import init_sandbox_provider, shutdown_sandbox_provider +from yuxi.knowledge import knowledge_base from yuxi.services.mcp_service import ensure_builtin_mcp_servers_in_db from yuxi.services.model_provider_service import ensure_builtin_model_providers_in_db -from yuxi.services.subagent_service import init_builtin_subagents from yuxi.services.run_queue_service import close_queue_clients, get_redis_client +from yuxi.services.subagent_service import init_builtin_subagents +from yuxi.services.task_service import tasker from yuxi.storage.postgres.manager import pg_manager -from yuxi.knowledge import knowledge_base from yuxi.utils import logger -from yuxi.agents.backends.sandbox import init_sandbox_provider, shutdown_sandbox_provider -from yuxi import get_version @asynccontextmanager @@ -88,6 +87,24 @@ async def lifespan(app: FastAPI): print("LangGraph Checkpoint tables verified/created!") await tasker.start() + + try: + from yuxi.channel.infrastructure.agent.agent_adapter import AgentAdapter + from yuxi.channel.startup import init_channel, register_channel_middleware + + redis_url = os.getenv("REDIS_URL", "redis://redis:6379/0") + config_path = os.getenv("CHANNEL_CONFIG_PATH", "channel_config.yaml") + await init_channel( + redis_url=redis_url, + agent_port=AgentAdapter(pg_manager.get_async_session_context), + config_yaml_path=config_path, + ) + register_channel_middleware(app) + logger.info("Channel gateway initialized") + except Exception as e: + logger.error(f"Channel gateway initialization failed: {e}") + raise + logger.info(f""" ░██ ░██ ░██ @@ -101,6 +118,14 @@ async def lifespan(app: FastAPI): """) logger.info("Yuxi backend startup complete") yield + try: + from yuxi.channel.startup import get_container, shutdown_channel + + container = get_container() + if container: + await shutdown_channel(container) + except Exception as e: + logger.error(f"Channel gateway shutdown error: {e}") await tasker.shutdown() shutdown_sandbox_provider() await close_queue_clients() diff --git a/backend/test/integration/channel/__init__.py b/backend/test/integration/channel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/integration/channel/conftest.py b/backend/test/integration/channel/conftest.py new file mode 100644 index 00000000..c71df26c --- /dev/null +++ b/backend/test/integration/channel/conftest.py @@ -0,0 +1,29 @@ +""" +Shared pytest fixtures for channel integration tests. + +Channel endpoints use their own auth service (token / password) which is +separate from the main app JWT auth. When ``auth.token`` and +``auth.password`` in ``channel_config.yaml`` are both empty the channel +auth service accepts every request – this is the default dev setup. +""" + +from __future__ import annotations + +import os + +import pytest + +CHANNEL_AUTH_TOKEN = os.getenv("CHANNEL_AUTH_TOKEN", "") +CHANNEL_AUTH_PASSWORD = os.getenv("CHANNEL_AUTH_PASSWORD", "") + + +@pytest.fixture() +def channel_auth_headers() -> dict[str, str]: + if CHANNEL_AUTH_TOKEN: + return {"Authorization": f"Bearer {CHANNEL_AUTH_TOKEN}"} + if CHANNEL_AUTH_PASSWORD: + import base64 + + encoded = base64.b64encode(f"mgmt:{CHANNEL_AUTH_PASSWORD}".encode()).decode() + return {"Authorization": f"Basic {encoded}"} + return {} diff --git a/backend/test/integration/channel/test_bindings_router.py b/backend/test/integration/channel/test_bindings_router.py new file mode 100644 index 00000000..908095cb --- /dev/null +++ b/backend/test/integration/channel/test_bindings_router.py @@ -0,0 +1,176 @@ +""" +Integration tests for channel bindings CRUD endpoints (/channel/bindings). +""" + +from __future__ import annotations + +import uuid + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + +_BINDING_SEED = uuid.uuid4().hex[:8] + + +async def _create_binding(test_client, headers, *, channel_type="web", agent_config_id=1): + account_id = f"intg_test_{_BINDING_SEED}" + group_id = f"group_{_BINDING_SEED}" + response = await test_client.post( + "/channel/bindings", + json={ + "channel_type": channel_type, + "account_id": account_id, + "group_id": group_id, + "agent_config_id": agent_config_id, + }, + headers=headers, + ) + return response + + +async def test_create_binding_returns_201(test_client, channel_auth_headers): + response = await _create_binding(test_client, channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 201, response.text + payload = response.json() + assert "id" in payload + assert payload["channel_type"] == "web" + assert payload["is_enabled"] is True + + await test_client.delete(f"/channel/bindings/{payload['id']}", headers=channel_auth_headers) + + +async def test_create_binding_response_has_required_fields(test_client, channel_auth_headers): + response = await _create_binding(test_client, channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 201, response.text + payload = response.json() + for field in ("id", "channel_type", "account_id", "group_id", "agent_config_id", "is_enabled", "created_at"): + assert field in payload, f"missing field: {field}" + + await test_client.delete(f"/channel/bindings/{payload['id']}", headers=channel_auth_headers) + + +async def test_create_binding_with_empty_channel_type_returns_422(test_client, channel_auth_headers): + response = await test_client.post( + "/channel/bindings", + json={ + "channel_type": "", + "account_id": "test_acct", + "group_id": "", + "agent_config_id": 1, + }, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 422 + + +async def test_create_binding_with_invalid_agent_config_id_returns_422(test_client, channel_auth_headers): + response = await test_client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "test_acct", + "group_id": "", + "agent_config_id": 0, + }, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 422 + + +async def test_list_bindings_returns_paginated_result(test_client, channel_auth_headers): + response = await test_client.get("/channel/bindings", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + assert "items" in payload + assert "meta" in payload + meta = payload["meta"] + for field in ("total", "offset", "limit"): + assert field in meta, f"missing meta field: {field}" + + +async def test_list_bindings_filter_by_channel_type(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/bindings", + params={"channel_type": "web"}, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + for item in payload.get("items", []): + assert item["channel_type"] == "web" + + +async def test_list_bindings_pagination(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/bindings", + params={"offset": 0, "limit": 1}, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + assert payload["meta"]["limit"] == 1 + assert len(payload["items"]) <= 1 + + +async def test_delete_binding_returns_ok(test_client, channel_auth_headers): + create_resp = await _create_binding(test_client, channel_auth_headers) + if create_resp.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert create_resp.status_code == 201, create_resp.text + binding_id = create_resp.json()["id"] + + delete_resp = await test_client.delete( + f"/channel/bindings/{binding_id}", + headers=channel_auth_headers, + ) + assert delete_resp.status_code == 200, delete_resp.text + assert delete_resp.json()["ok"] is True + + +async def test_delete_nonexistent_binding_returns_404(test_client, channel_auth_headers): + response = await test_client.delete( + "/channel/bindings/99999999", + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 404 + + +async def test_binding_full_lifecycle(test_client, channel_auth_headers): + create_resp = await _create_binding(test_client, channel_auth_headers) + if create_resp.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert create_resp.status_code == 201, create_resp.text + binding = create_resp.json() + binding_id = binding["id"] + + list_resp = await test_client.get("/channel/bindings", headers=channel_auth_headers) + assert list_resp.status_code == 200 + ids = [b["id"] for b in list_resp.json()["items"]] + assert binding_id in ids + + delete_resp = await test_client.delete( + f"/channel/bindings/{binding_id}", + headers=channel_auth_headers, + ) + assert delete_resp.status_code == 200 + + list_resp2 = await test_client.get("/channel/bindings", headers=channel_auth_headers) + ids2 = [b["id"] for b in list_resp2.json()["items"]] + assert binding_id not in ids2 diff --git a/backend/test/integration/channel/test_channel_inbound_router.py b/backend/test/integration/channel/test_channel_inbound_router.py new file mode 100644 index 00000000..4042f4fb --- /dev/null +++ b/backend/test/integration/channel/test_channel_inbound_router.py @@ -0,0 +1,116 @@ +""" +Integration tests for channel inbound endpoints: + - /channel/web/message + - /channel/feishu/event + - /channel/feishu/verify + - /channel/hooks/{path} + - /channel/dingtalk/callback +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +class TestWebMessage: + async def test_web_message_with_invalid_json_returns_400(self, test_client): + response = await test_client.post( + "/channel/web/message", + content=b"not-json", + headers={"Content-Type": "application/json"}, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 400, response.text + + async def test_web_message_with_valid_json_returns_accepted(self, test_client): + response = await test_client.post( + "/channel/web/message", + json={ + "session_id": "intg-test-session", + "content": "hello from integration test", + "sender": "tester", + }, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code in (202, 400), response.text + if response.status_code == 202: + payload = response.json() + assert "trace_id" in payload + assert payload["status"] in ("accepted", "skipped") + + +class TestFeishuEvent: + async def test_feishu_url_verification_returns_challenge(self, test_client): + challenge = "test_challenge_value_123" + response = await test_client.post( + "/channel/feishu/event", + json={ + "type": "url_verification", + "challenge": challenge, + }, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + assert response.json()["challenge"] == challenge + + async def test_feishu_event_without_type_returns_code_0(self, test_client): + response = await test_client.post( + "/channel/feishu/event", + json={"event": {"message": {"content": "test"}}}, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + assert response.json().get("code") == 0 + + async def test_feishu_verify_endpoint(self, test_client): + response = await test_client.get( + "/channel/feishu/verify", + params={"challenge": "abc", "token": "any"}, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + if response.status_code == 403: + pytest.skip("Feishu verification token mismatch") + assert response.status_code == 200, response.text + + +class TestHooksMessage: + async def test_hooks_with_nonexistent_path_returns_404(self, test_client): + response = await test_client.post( + "/channel/hooks/nonexistent_path_xyz", + json={"event": "test"}, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 404, response.text + + async def test_hooks_with_invalid_json_returns_400(self, test_client): + response = await test_client.post( + "/channel/hooks/test_path", + content=b"not-json", + headers={"Content-Type": "application/json"}, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + if response.status_code == 404: + pytest.skip("Hook mapping not configured") + assert response.status_code == 400, response.text + + +class TestDingTalkCallback: + async def test_dingtalk_callback_returns_accepted(self, test_client): + response = await test_client.post( + "/channel/dingtalk/callback", + json={"msgtype": "text", "text": {"content": "hello"}}, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 202, response.text + payload = response.json() + assert payload.get("status") == "accepted" diff --git a/backend/test/integration/channel/test_channel_status_router.py b/backend/test/integration/channel/test_channel_status_router.py new file mode 100644 index 00000000..9cee9476 --- /dev/null +++ b/backend/test/integration/channel/test_channel_status_router.py @@ -0,0 +1,55 @@ +""" +Integration tests for channel status endpoint (/channel/status). +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_channel_status_returns_list(test_client, channel_auth_headers): + response = await test_client.get("/channel/status", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + assert isinstance(payload, list) + + +async def test_channel_status_item_has_required_fields(test_client, channel_auth_headers): + response = await test_client.get("/channel/status", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + if not payload: + pytest.skip("No channel adapters registered") + item = payload[0] + for field in ("channel_type", "healthy", "capabilities", "active_sessions"): + assert field in item, f"missing field: {field}" + + +async def test_channel_status_capabilities_structure(test_client, channel_auth_headers): + response = await test_client.get("/channel/status", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + if not payload: + pytest.skip("No channel adapters registered") + caps = payload[0]["capabilities"] + for field in ("media", "group", "dm", "streaming", "typing", "max_text_length"): + assert field in caps, f"missing capability field: {field}" + + +async def test_channel_status_healthy_is_boolean(test_client, channel_auth_headers): + response = await test_client.get("/channel/status", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + if not payload: + pytest.skip("No channel adapters registered") + assert isinstance(payload[0]["healthy"], bool) diff --git a/backend/test/integration/channel/test_config_reload_router.py b/backend/test/integration/channel/test_config_reload_router.py new file mode 100644 index 00000000..81f6b464 --- /dev/null +++ b/backend/test/integration/channel/test_config_reload_router.py @@ -0,0 +1,39 @@ +""" +Integration tests for channel config reload endpoint (/channel/config/reload). +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_config_reload_returns_reloaded(test_client, channel_auth_headers): + response = await test_client.post("/channel/config/reload", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + assert payload["status"] == "reloaded" + assert "updated_middlewares" in payload + + +async def test_config_reload_response_has_config_hash(test_client, channel_auth_headers): + response = await test_client.post("/channel/config/reload", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + assert "config_hash" in payload + + +async def test_config_reload_idempotent(test_client, channel_auth_headers): + first = await test_client.post("/channel/config/reload", headers=channel_auth_headers) + if first.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert first.status_code == 200 + + second = await test_client.post("/channel/config/reload", headers=channel_auth_headers) + assert second.status_code == 200 + assert second.json()["status"] == "reloaded" diff --git a/backend/test/integration/channel/test_health_router.py b/backend/test/integration/channel/test_health_router.py new file mode 100644 index 00000000..f1a4d69b --- /dev/null +++ b/backend/test/integration/channel/test_health_router.py @@ -0,0 +1,57 @@ +""" +Integration tests for channel health endpoints (/healthz, /readyz). +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_healthz_returns_ok(test_client): + response = await test_client.get("/healthz") + assert response.status_code == 200, response.text + payload = response.json() + assert payload["status"] == "ok" + + +async def test_healthz_includes_startup_timeline(test_client): + response = await test_client.get("/healthz") + assert response.status_code == 200, response.text + payload = response.json() + timeline = payload.get("startup_timeline") + if timeline is not None: + assert isinstance(timeline, list) + if timeline: + assert "name" in timeline[0] + assert "status" in timeline[0] + + +async def test_readyz_returns_200_when_services_ready(test_client): + response = await test_client.get("/readyz") + if response.status_code == 503: + pytest.skip("Channel services not fully ready in this environment") + assert response.status_code == 200, response.text + payload = response.json() + assert payload["status"] == "ready" + + +async def test_readyz_includes_component_checks(test_client): + response = await test_client.get("/readyz") + payload = response.json() + for key in ("redis", "postgres", "workers", "channels", "ws_connections"): + component = payload.get(key) + if component is not None: + assert "status" in component, f"component {key} missing 'status' field" + + +async def test_readyz_returns_503_when_degraded(test_client): + response = await test_client.get("/readyz") + if response.status_code == 503: + payload = response.json() + assert payload["status"] == "not_ready" + components = {k: v for k, v in payload.items() if isinstance(v, dict) and "status" in v} + assert any(v["status"] != "ok" for v in components.values()), ( + "503 returned but all components report ok" + ) diff --git a/backend/test/integration/channel/test_metrics_router.py b/backend/test/integration/channel/test_metrics_router.py new file mode 100644 index 00000000..fd447629 --- /dev/null +++ b/backend/test/integration/channel/test_metrics_router.py @@ -0,0 +1,26 @@ +""" +Integration tests for channel metrics endpoint (/metrics). +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_metrics_returns_prometheus_format(test_client): + response = await test_client.get("/metrics") + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + assert "text/plain" in response.headers.get("content-type", "") + body = response.text + assert "# HELP" in body or "# TYPE" in body or body.strip() == "" + + +async def test_metrics_no_auth_required(test_client): + response = await test_client.get("/metrics") + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code != 401, "metrics endpoint should not require auth" diff --git a/backend/test/integration/channel/test_plugin_registry_router.py b/backend/test/integration/channel/test_plugin_registry_router.py new file mode 100644 index 00000000..50ae886f --- /dev/null +++ b/backend/test/integration/channel/test_plugin_registry_router.py @@ -0,0 +1,162 @@ +""" +Integration tests for channel plugin registry endpoints (/channel/plugins). +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_list_plugins_returns_plugin_list(test_client, channel_auth_headers): + response = await test_client.get("/channel/plugins", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + assert "plugins" in payload + assert "total" in payload + assert isinstance(payload["plugins"], list) + + +async def test_list_plugins_filter_by_status(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/plugins", + params={"status": "activated"}, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + for plugin in payload.get("plugins", []): + assert plugin["status"] == "activated" + + +async def test_list_plugins_filter_by_origin(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/plugins", + params={"origin": "bundled"}, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + + +async def test_list_plugins_item_has_required_fields(test_client, channel_auth_headers): + response = await test_client.get("/channel/plugins", headers=channel_auth_headers) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 200, response.text + payload = response.json() + if not payload["plugins"]: + pytest.skip("No plugins registered") + plugin = payload["plugins"][0] + for field in ("plugin_id", "name", "version", "channel_type", "status", "source", "is_configured"): + assert field in plugin, f"missing field: {field}" + + +async def test_get_plugin_detail(test_client, channel_auth_headers): + list_resp = await test_client.get("/channel/plugins", headers=channel_auth_headers) + if list_resp.status_code == 503: + pytest.skip("Channel gateway not initialized") + plugins = list_resp.json().get("plugins", []) + if not plugins: + pytest.skip("No plugins registered") + + plugin_id = plugins[0]["plugin_id"] + detail_resp = await test_client.get( + f"/channel/plugins/{plugin_id}", + headers=channel_auth_headers, + ) + assert detail_resp.status_code == 200, detail_resp.text + detail = detail_resp.json() + assert detail["plugin_id"] == plugin_id + for field in ("name", "version", "channel_type", "status", "capabilities", "config_schema"): + assert field in detail, f"missing detail field: {field}" + + +async def test_get_nonexistent_plugin_returns_404(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/plugins/nonexistent_plugin_xyz", + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 404 + + +async def test_get_plugin_manifest(test_client, channel_auth_headers): + list_resp = await test_client.get("/channel/plugins", headers=channel_auth_headers) + if list_resp.status_code == 503: + pytest.skip("Channel gateway not initialized") + plugins = list_resp.json().get("plugins", []) + if not plugins: + pytest.skip("No plugins registered") + + plugin_id = plugins[0]["plugin_id"] + manifest_resp = await test_client.get( + f"/channel/plugins/{plugin_id}/manifest", + headers=channel_auth_headers, + ) + assert manifest_resp.status_code == 200, manifest_resp.text + + +async def test_get_nonexistent_plugin_manifest_returns_404(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/plugins/nonexistent_plugin_xyz/manifest", + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 404 + + +async def test_get_plugin_config_schema(test_client, channel_auth_headers): + list_resp = await test_client.get("/channel/plugins", headers=channel_auth_headers) + if list_resp.status_code == 503: + pytest.skip("Channel gateway not initialized") + plugins = list_resp.json().get("plugins", []) + if not plugins: + pytest.skip("No plugins registered") + + plugin_id = plugins[0]["plugin_id"] + schema_resp = await test_client.get( + f"/channel/plugins/{plugin_id}/config-schema", + headers=channel_auth_headers, + ) + assert schema_resp.status_code == 200, schema_resp.text + assert "config_schema" in schema_resp.json() + + +async def test_activate_nonexistent_plugin_returns_404(test_client, channel_auth_headers): + response = await test_client.post( + "/channel/plugins/nonexistent_plugin_xyz/activate", + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 404 + + +async def test_deactivate_nonexistent_plugin_returns_404(test_client, channel_auth_headers): + response = await test_client.post( + "/channel/plugins/nonexistent_plugin_xyz/deactivate", + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 404 + + +async def test_reload_nonexistent_plugin_returns_404(test_client, channel_auth_headers): + response = await test_client.post( + "/channel/plugins/nonexistent_plugin_xyz/reload", + json={"force": False}, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 404 diff --git a/backend/test/integration/channel/test_sse_router.py b/backend/test/integration/channel/test_sse_router.py new file mode 100644 index 00000000..4a427984 --- /dev/null +++ b/backend/test/integration/channel/test_sse_router.py @@ -0,0 +1,54 @@ +""" +Integration tests for channel SSE endpoint (/channel/sse). +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.asyncio, pytest.mark.integration] + + +async def test_sse_with_invalid_session_id_returns_400(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/sse", + params={"session_id": "invalid session!@#"}, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 400, response.text + assert "invalid session_id" in response.json().get("detail", "").lower() + + +async def test_sse_with_empty_session_id_returns_422(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/sse", + params={"session_id": ""}, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 422 + + +async def test_sse_without_session_id_returns_422(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/sse", + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + assert response.status_code == 422 + + +async def test_sse_with_valid_session_id_format(test_client, channel_auth_headers): + response = await test_client.get( + "/channel/sse", + params={"session_id": "test-session-123"}, + headers=channel_auth_headers, + ) + if response.status_code == 503: + pytest.skip("Channel gateway not initialized") + if response.status_code == 200: + assert "text/event-stream" in response.headers.get("content-type", "") diff --git a/backend/test/unit/channel/__init__.py b/backend/test/unit/channel/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/__init__.py +++ b/backend/test/unit/channel/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/application/__init__.py b/backend/test/unit/channel/application/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/application/__init__.py +++ b/backend/test/unit/channel/application/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/application/command/__init__.py b/backend/test/unit/channel/application/command/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/application/command/plugin/__init__.py b/backend/test/unit/channel/application/command/plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/application/command/plugin/test_commands.py b/backend/test/unit/channel/application/command/plugin/test_commands.py new file mode 100644 index 00000000..cb294085 --- /dev/null +++ b/backend/test/unit/channel/application/command/plugin/test_commands.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from yuxi.channel.application.command.plugin.activate_command import ActivateCommand +from yuxi.channel.application.command.plugin.deactivate_command import DeactivateCommand +from yuxi.channel.application.command.plugin.register_command import RegisterCommand +from yuxi.channel.application.command.plugin.reload_command import ReloadCommand + + +def test_activate_command_defaults() -> None: + cmd = ActivateCommand(plugin_id="web") + assert cmd.plugin_id == "web" + assert cmd.config_override is None + + +def test_activate_command_with_override() -> None: + cmd = ActivateCommand(plugin_id="feishu", config_override={"app_id": "x"}) + assert cmd.config_override == {"app_id": "x"} + + +def test_deactivate_command() -> None: + cmd = DeactivateCommand(plugin_id="hooks") + assert cmd.plugin_id == "hooks" + + +def test_register_command_defaults() -> None: + cmd = RegisterCommand(plugin_id="custom") + assert cmd.plugin_id == "custom" + assert cmd.manifest_path is None + assert cmd.manifest_data is None + assert cmd.mode == "full" + + +def test_register_command_with_values() -> None: + cmd = RegisterCommand( + plugin_id="custom", + manifest_path="/path/to/manifest", + manifest_data={"key": "value"}, + mode="discovery", + ) + assert cmd.manifest_path == "/path/to/manifest" + assert cmd.manifest_data == {"key": "value"} + assert cmd.mode == "discovery" + + +def test_reload_command_defaults() -> None: + cmd = ReloadCommand(plugin_id="web") + assert cmd.plugin_id == "web" + assert cmd.force is False + + +def test_reload_command_force() -> None: + cmd = ReloadCommand(plugin_id="web", force=True) + assert cmd.force is True diff --git a/backend/test/unit/channel/application/dto/__init__.py b/backend/test/unit/channel/application/dto/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/application/dto/__init__.py +++ b/backend/test/unit/channel/application/dto/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/application/dto/test_plugin_dto.py b/backend/test/unit/channel/application/dto/test_plugin_dto.py new file mode 100644 index 00000000..c2d57d2c --- /dev/null +++ b/backend/test/unit/channel/application/dto/test_plugin_dto.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from yuxi.channel.application.dto.plugin_dto import ( + PluginDetailDTO, + PluginListDTO, + PluginSummaryDTO, + ReloadResultDTO, +) + + +def test_plugin_summary_dto() -> None: + dto = PluginSummaryDTO( + plugin_id="feishu", + name="Feishu", + version="1.0.0", + channel_type="feishu", + status="active", + source="bundled", + is_configured=True, + ) + assert dto.plugin_id == "feishu" + assert dto.name == "Feishu" + assert dto.status == "active" + assert dto.is_configured is True + + +def test_plugin_detail_dto() -> None: + dto = PluginDetailDTO( + plugin_id="web", + name="Web", + version="2.0.0", + channel_type="web", + status="configured", + source="bundled", + is_configured=True, + capabilities={"media": True, "streaming": True}, + config_schema={"type": "object"}, + registered_at="2025-01-01", + error=None, + ) + assert dto.capabilities == {"media": True, "streaming": True} + assert dto.config_schema == {"type": "object"} + assert dto.error is None + + +def test_plugin_detail_dto_defaults() -> None: + dto = PluginDetailDTO( + plugin_id="x", + name="X", + version="0.1", + channel_type="x", + status="discovered", + source="installed", + is_configured=False, + capabilities={}, + ) + assert dto.config_schema is None + assert dto.registered_at is None + assert dto.error is None + + +def test_plugin_list_dto() -> None: + summary = PluginSummaryDTO( + plugin_id="a", name="A", version="1.0", channel_type="a", status="active", source="bundled", is_configured=True + ) + dto = PluginListDTO(plugins=[summary], total=1) + assert len(dto.plugins) == 1 + assert dto.total == 1 + + +def test_reload_result_dto() -> None: + dto = ReloadResultDTO(plugin_id="web", old_version="1.0", new_version="2.0", success=True) + assert dto.old_version == "1.0" + assert dto.new_version == "2.0" + assert dto.success is True diff --git a/backend/test/unit/channel/application/event/__init__.py b/backend/test/unit/channel/application/event/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/application/event/handler/__init__.py b/backend/test/unit/channel/application/event/handler/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/application/event/handler/test_plugin_event_handler.py b/backend/test/unit/channel/application/event/handler/test_plugin_event_handler.py new file mode 100644 index 00000000..f25b934f --- /dev/null +++ b/backend/test/unit/channel/application/event/handler/test_plugin_event_handler.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import logging + +from yuxi.channel.application.event.handler.plugin_event_handler import PluginEventHandler +from yuxi.channel.domain.event.plugin.plugin_activated_event import PluginActivatedEvent +from yuxi.channel.domain.event.plugin.plugin_deactivated_event import PluginDeactivatedEvent +from yuxi.channel.domain.event.plugin.plugin_registered_event import PluginRegisteredEvent +from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent + + +def test_on_registered(caplog) -> None: + handler = PluginEventHandler() + event = PluginRegisteredEvent(plugin_id="web", channel_type="web") + with caplog.at_level(logging.INFO): + handler.on_registered(event) + assert "web" in caplog.text + + +def test_on_activated(caplog) -> None: + handler = PluginEventHandler() + event = PluginActivatedEvent(plugin_id="feishu", channel_type="feishu") + with caplog.at_level(logging.INFO): + handler.on_activated(event) + assert "feishu" in caplog.text + + +def test_on_deactivated(caplog) -> None: + handler = PluginEventHandler() + event = PluginDeactivatedEvent(plugin_id="hooks", channel_type="hooks") + with caplog.at_level(logging.INFO): + handler.on_deactivated(event) + assert "hooks" in caplog.text + + +def test_on_reloaded(caplog) -> None: + handler = PluginEventHandler() + event = PluginReloadedEvent( + plugin_id="web", + old_version="1.0", + new_version="2.0", + channel_type="web", + ) + with caplog.at_level(logging.INFO): + handler.on_reloaded(event) + assert "1.0" in caplog.text + assert "2.0" in caplog.text diff --git a/backend/test/unit/channel/application/pipeline/__init__.py b/backend/test/unit/channel/application/pipeline/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/application/pipeline/__init__.py +++ b/backend/test/unit/channel/application/pipeline/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/application/pipeline/middlewares/__init__.py b/backend/test/unit/channel/application/pipeline/middlewares/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/application/pipeline/middlewares/__init__.py +++ b/backend/test/unit/channel/application/pipeline/middlewares/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/application/pipeline/middlewares/test_access_policy_middleware.py b/backend/test/unit/channel/application/pipeline/middlewares/test_access_policy_middleware.py index 360a5d0b..e147f7b0 100644 --- a/backend/test/unit/channel/application/pipeline/middlewares/test_access_policy_middleware.py +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_access_policy_middleware.py @@ -110,9 +110,7 @@ async def test_group_disabled_policy() -> None: @pytest.mark.asyncio async def test_group_allowlist_allowed() -> None: - mw = AccessPolicyMiddleware( - policies={"web": {"group_policy": "allowlist", "group_allowlist": ["grp-1"]}} - ) + mw = AccessPolicyMiddleware(policies={"web": {"group_policy": "allowlist", "group_allowlist": ["grp-1"]}}) ctx = _ctx(is_group=True, group_id="grp-1") result = await mw.process(ctx, _call_next) assert result.is_aborted is False @@ -120,9 +118,7 @@ async def test_group_allowlist_allowed() -> None: @pytest.mark.asyncio async def test_group_allowlist_blocked() -> None: - mw = AccessPolicyMiddleware( - policies={"web": {"group_policy": "allowlist", "group_allowlist": ["grp-2"]}} - ) + mw = AccessPolicyMiddleware(policies={"web": {"group_policy": "allowlist", "group_allowlist": ["grp-2"]}}) ctx = _ctx(is_group=True, group_id="grp-1") result = await mw.process(ctx, _call_next) assert result.is_aborted is True diff --git a/backend/test/unit/channel/application/pipeline/middlewares/test_enqueue_mq_middleware.py b/backend/test/unit/channel/application/pipeline/middlewares/test_enqueue_mq_middleware.py index eaec6dfd..560f169e 100644 --- a/backend/test/unit/channel/application/pipeline/middlewares/test_enqueue_mq_middleware.py +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_enqueue_mq_middleware.py @@ -77,7 +77,9 @@ async def test_enqueue_with_attachments() -> None: queue = _FakeQueue() mw = EnqueueMQMiddleware(queue) attachments = [ - Attachment(url="http://example.com/a.png", media_type="image", filename="a.png", size_bytes=1024, mime_type="image/png") + Attachment( + url="http://example.com/a.png", media_type="image", filename="a.png", size_bytes=1024, mime_type="image/png" + ) ] ctx = _ctx(attachments=attachments) result = await mw.process(ctx, _call_next) diff --git a/backend/test/unit/channel/application/pipeline/middlewares/test_rate_limit_middleware.py b/backend/test/unit/channel/application/pipeline/middlewares/test_rate_limit_middleware.py index b5e1ad68..9dae6a76 100644 --- a/backend/test/unit/channel/application/pipeline/middlewares/test_rate_limit_middleware.py +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_rate_limit_middleware.py @@ -19,9 +19,7 @@ class _FakeRateLimiter: async def check_and_incr( self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0 ) -> bool: - self.calls.append( - {"key": key, "max_attempts": max_attempts, "window_seconds": window_seconds} - ) + self.calls.append({"key": key, "max_attempts": max_attempts, "window_seconds": window_seconds}) return self._allow async def is_locked(self, key: str) -> tuple[bool, int]: diff --git a/backend/test/unit/channel/application/query/__init__.py b/backend/test/unit/channel/application/query/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/application/query/plugin/__init__.py b/backend/test/unit/channel/application/query/plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/application/query/plugin/test_queries.py b/backend/test/unit/channel/application/query/plugin/test_queries.py new file mode 100644 index 00000000..7b22a944 --- /dev/null +++ b/backend/test/unit/channel/application/query/plugin/test_queries.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from yuxi.channel.application.query.plugin.get_plugin_manifest_query import GetPluginManifestQuery +from yuxi.channel.application.query.plugin.list_plugins_query import ListPluginsQuery + + +def test_get_plugin_manifest_query() -> None: + query = GetPluginManifestQuery(plugin_id="feishu") + assert query.plugin_id == "feishu" + + +def test_list_plugins_query_defaults() -> None: + query = ListPluginsQuery() + assert query.origin is None + assert query.status is None + + +def test_list_plugins_query_with_filters() -> None: + query = ListPluginsQuery(origin="bundled", status="active") + assert query.origin == "bundled" + assert query.status == "active" diff --git a/backend/test/unit/channel/application/service/__init__.py b/backend/test/unit/channel/application/service/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/application/service/__init__.py +++ b/backend/test/unit/channel/application/service/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/application/service/test_binding_service.py b/backend/test/unit/channel/application/service/test_binding_service.py index eb1fee43..d952cc37 100644 --- a/backend/test/unit/channel/application/service/test_binding_service.py +++ b/backend/test/unit/channel/application/service/test_binding_service.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Any - import pytest from yuxi.channel.application.service.binding_service import BindingService @@ -14,9 +12,7 @@ class _FakeBindingRepo: self.next_id = 1 self.deleted: list[int] = [] - async def find_binding( - self, *, channel_type: str, account_id: str, group_id: str - ) -> ChannelBinding | None: + async def find_binding(self, *, channel_type: str, account_id: str, group_id: str) -> ChannelBinding | None: for b in self.bindings.values(): if b.channel_type == channel_type and b.account_id == account_id and b.group_id == group_id: return b @@ -80,11 +76,6 @@ class _FakeBindingRepo: items = items[offset : offset + limit] return items, total - async def invalidate_cache( - self, *, channel_type: str, account_id: str, group_id: str - ) -> None: - pass - @pytest.fixture def fake_repo() -> _FakeBindingRepo: @@ -113,9 +104,7 @@ async def test_create_binding(binding_service: BindingService) -> None: @pytest.mark.asyncio async def test_delete_binding_success(binding_service: BindingService, fake_repo: _FakeBindingRepo) -> None: - created = await binding_service.create( - channel_type="web", account_id="acc-2", group_id="grp-2", agent_config_id=10 - ) + created = await binding_service.create(channel_type="web", account_id="acc-2", group_id="grp-2", agent_config_id=10) result = await binding_service.delete(created.id) assert result is True assert created.id in fake_repo.deleted @@ -162,9 +151,7 @@ async def test_list_bindings(binding_service: BindingService) -> None: @pytest.mark.asyncio async def test_list_bindings_pagination(binding_service: BindingService) -> None: for i in range(5): - await binding_service.create( - channel_type="web", account_id=f"a{i}", group_id=f"g{i}", agent_config_id=i - ) + await binding_service.create(channel_type="web", account_id=f"a{i}", group_id=f"g{i}", agent_config_id=i) items, total = await binding_service.list(offset=2, limit=2) assert total == 5 assert len(items) == 2 diff --git a/backend/test/unit/channel/application/service/test_config_service.py b/backend/test/unit/channel/application/service/test_config_service.py index 10bd71c8..39cd3ddf 100644 --- a/backend/test/unit/channel/application/service/test_config_service.py +++ b/backend/test/unit/channel/application/service/test_config_service.py @@ -75,9 +75,7 @@ def pipeline_with_configurable() -> Pipeline: @pytest.mark.asyncio -async def test_reload_updates_config( - fake_config_reload: _FakeConfigReload, empty_pipeline: Pipeline -) -> None: +async def test_reload_updates_config(fake_config_reload: _FakeConfigReload, empty_pipeline: Pipeline) -> None: service = ConfigService( config_data={"old": "data"}, config_reload=fake_config_reload, diff --git a/backend/test/unit/channel/application/service/test_delivery_service.py b/backend/test/unit/channel/application/service/test_delivery_service.py index ac98a929..1bd4967b 100644 --- a/backend/test/unit/channel/application/service/test_delivery_service.py +++ b/backend/test/unit/channel/application/service/test_delivery_service.py @@ -67,9 +67,7 @@ class _FakeAdapter: async def receive_message(self, raw: dict) -> Any: pass - async def send_message( - self, session_id: str, content: str, *, channel_type: str, metadata: dict - ) -> SendResult: + async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult: self.sent_messages.append((session_id, content, metadata)) if self._send_success: return SendResult(success=True) @@ -78,9 +76,7 @@ class _FakeAdapter: async def send_typing(self, session_id: str) -> None: self.typing_calls.append(session_id) - async def send_media( - self, session_id: str, *, url: str, media_type: str, metadata: dict - ) -> bool: + async def send_media(self, session_id: str, *, url: str, media_type: str, metadata: dict) -> bool: self.sent_media.append((session_id, url, media_type, metadata)) return True @@ -101,9 +97,7 @@ class _FakeMessageRepo: ) -> Any: pass - async def save_assistant_message( - self, *, thread_id: str, content: str, extra_metadata: dict | None = None - ) -> Any: + async def save_assistant_message(self, *, thread_id: str, content: str, extra_metadata: dict | None = None) -> Any: self.saved_assistant.append((thread_id, content)) return Any @@ -133,7 +127,7 @@ class _FakeOutboxRepo: ) return Any - async def fetch_pending(self, *, limit: int = 20) -> list: + async def fetch_and_lock(self, *, limit: int = 20, worker_id: str = "") -> list: return [] async def get_by_id(self, entry_id: int) -> Any: @@ -151,9 +145,7 @@ class _FakeOutboxRepo: async def mark_failed(self, entry_id: int, *, last_error: str) -> bool: return True - async def list_outbox( - self, *, channel_type: str | None = None, status: str | None = None, limit: int = 50 - ) -> list: + async def list_outbox(self, *, channel_type: str | None = None, status: str | None = None, limit: int = 50) -> list: return [] diff --git a/backend/test/unit/channel/application/service/test_dispatch_service.py b/backend/test/unit/channel/application/service/test_dispatch_service.py index 2623b3b0..8d472686 100644 --- a/backend/test/unit/channel/application/service/test_dispatch_service.py +++ b/backend/test/unit/channel/application/service/test_dispatch_service.py @@ -14,7 +14,9 @@ from yuxi.channel.domain.model.session.channel_session import ChannelSession class _FakeContentFilter: - def __init__(self, passed: bool = True, violations: list[str] | None = None, masked: list[str] | None = None) -> None: + def __init__( + self, passed: bool = True, violations: list[str] | None = None, masked: list[str] | None = None + ) -> None: self._passed = passed self._violations = violations or [] self._masked = masked or [] diff --git a/backend/test/unit/channel/application/service/test_plugin_registry_app_service.py b/backend/test/unit/channel/application/service/test_plugin_registry_app_service.py new file mode 100644 index 00000000..4ba72524 --- /dev/null +++ b/backend/test/unit/channel/application/service/test_plugin_registry_app_service.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.application.command.plugin.activate_command import ActivateCommand +from yuxi.channel.application.command.plugin.deactivate_command import DeactivateCommand +from yuxi.channel.application.command.plugin.reload_command import ReloadCommand +from yuxi.channel.application.service.plugin_registry_app_service import PluginRegistryAppService +from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent +from yuxi.channel.domain.exception.plugin_exception import ( + PluginNotFoundException, + PluginReloadException, +) +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.infrastructure.plugin.registry_impl import PluginRegistryImpl + + +class FakeAdapter: + def __init__(self, **kwargs) -> None: + self.config = kwargs + self._open = False + + async def open(self) -> None: + self._open = True + + async def close(self) -> None: + pass + + @property + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities() + + @property + def channel_type(self) -> str: + return "fake" + + @classmethod + def get_default_config(cls) -> dict: + return {} + + +def _make_record( + plugin_id: str = "test", + channel_type: str = "test", + status: PluginStatus = PluginStatus.REGISTERED, + adapter_class: type | None = None, +) -> PluginRecord: + manifest = PluginManifest( + id=plugin_id, + name=plugin_id.capitalize(), + version="1.0.0", + channel_type=channel_type, + capabilities=ChannelCapabilities(), + ) + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir=f"channels/{channel_type}", + entry_file=f"channels/{channel_type}/adapter.py", + ) + record = PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id=plugin_id, + name=plugin_id.capitalize(), + version="1.0.0", + channel_type=channel_type, + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + status=status, + ) + if adapter_class: + record.set_adapter_class(adapter_class) + return record + + +@pytest.fixture +def app_service(): + impl = PluginRegistryImpl() + return PluginRegistryAppService(impl) + + +class TestListPlugins: + def test_list_plugins_empty(self, app_service): + dto = app_service.list_plugins() + assert dto.total == 0 + assert dto.plugins == [] + + def test_list_plugins_with_records(self, app_service): + app_service._registry.register_builtin(_make_record(plugin_id="a")) + app_service._registry.register_builtin(_make_record(plugin_id="b")) + dto = app_service.list_plugins() + assert dto.total == 2 + assert {p.plugin_id for p in dto.plugins} == {"a", "b"} + + def test_list_plugins_filter_by_origin(self, app_service): + app_service._registry.register_builtin(_make_record(plugin_id="a")) + dto = app_service.list_plugins(origin="bundled") + assert dto.total == 1 + + def test_list_plugins_filter_by_status(self, app_service): + r = _make_record(plugin_id="a", status=PluginStatus.REGISTERED) + app_service._registry.register_builtin(r) + r.status = PluginStatus.ACTIVE + dto = app_service.list_plugins(status="active") + assert dto.total == 1 + assert dto.plugins[0].status == "active" + + +class TestGetPlugin: + def test_get_plugin_success(self, app_service): + app_service._registry.register_builtin(_make_record(plugin_id="feishu")) + dto = app_service.get_plugin("feishu") + assert dto.plugin_id == "feishu" + assert dto.name == "Feishu" + + def test_get_plugin_not_found_raises(self, app_service): + with pytest.raises(PluginNotFoundException): + app_service.get_plugin("missing") + + +class TestGetManifest: + def test_get_manifest_success(self, app_service): + app_service._registry.register_builtin(_make_record(plugin_id="feishu")) + manifest = app_service.get_manifest("feishu") + assert manifest["id"] == "feishu" + + def test_get_manifest_not_found_raises(self, app_service): + with pytest.raises(PluginNotFoundException): + app_service.get_manifest("missing") + + +class TestGetConfigSchema: + def test_get_config_schema_success(self, app_service): + record = _make_record(plugin_id="feishu") + record.config_schema = {"required": ["app_id"]} + app_service._registry.register_builtin(record) + schema = app_service.get_config_schema("feishu") + assert schema == {"required": ["app_id"]} + + def test_get_config_schema_not_found_raises(self, app_service): + with pytest.raises(PluginNotFoundException): + app_service.get_config_schema("missing") + + +class TestActivate: + @pytest.mark.asyncio + async def test_activate_success(self, app_service): + record = _make_record(plugin_id="feishu", channel_type="feishu", adapter_class=FakeAdapter) + record.set_config({"token": "t"}) + record.status = PluginStatus.REGISTERED + app_service._registry.register_builtin(record) + + cmd = ActivateCommand(plugin_id="feishu") + dto = await app_service.activate(cmd) + assert dto.status == "active" + assert app_service._registry.get_adapter("feishu") is not None + + @pytest.mark.asyncio + async def test_activate_with_config_override(self, app_service): + record = _make_record(plugin_id="feishu", channel_type="feishu", adapter_class=FakeAdapter) + record.set_config({"token": "old"}) + record.status = PluginStatus.REGISTERED + app_service._registry.register_builtin(record) + + cmd = ActivateCommand(plugin_id="feishu", config_override={"token": "new"}) + dto = await app_service.activate(cmd) + assert dto.status == "active" + adapter = app_service._registry.get_adapter("feishu") + assert adapter.config["token"] == "new" + + @pytest.mark.asyncio + async def test_activate_not_found_raises(self, app_service): + cmd = ActivateCommand(plugin_id="missing") + with pytest.raises(PluginNotFoundException): + await app_service.activate(cmd) + + +class TestDeactivate: + @pytest.mark.asyncio + async def test_deactivate_success(self, app_service): + record = _make_record(plugin_id="feishu", adapter_class=FakeAdapter) + record.set_config({"token": "t"}) + app_service._registry.register_builtin(record) + await app_service._registry.activate("feishu") + + cmd = DeactivateCommand(plugin_id="feishu") + dto = await app_service.deactivate(cmd) + assert dto.status == "disabled" + + @pytest.mark.asyncio + async def test_deactivate_not_found_raises(self, app_service): + cmd = DeactivateCommand(plugin_id="missing") + with pytest.raises(PluginNotFoundException): + await app_service.deactivate(cmd) + + +class TestReloadPlugin: + @pytest.mark.asyncio + async def test_reload_success(self, app_service): + record = _make_record(plugin_id="feishu", adapter_class=FakeAdapter) + record.set_config({"token": "old"}) + app_service._registry.register_builtin(record) + await app_service._registry.activate("feishu") + + cmd = ReloadCommand(plugin_id="feishu") + result = await app_service.reload_plugin(cmd) + assert result.success is True + assert result.plugin_id == "feishu" + + @pytest.mark.asyncio + async def test_reload_publishes_event(self, app_service): + publisher = AsyncMock() + app_service._event_publisher = publisher + + record = _make_record(plugin_id="feishu", adapter_class=FakeAdapter) + record.set_config({"token": "old"}) + app_service._registry.register_builtin(record) + await app_service._registry.activate("feishu") + + cmd = ReloadCommand(plugin_id="feishu") + await app_service.reload_plugin(cmd) + + publisher.publish.assert_awaited_once() + event = publisher.publish.await_args[0][0] + assert isinstance(event, PluginReloadedEvent) + assert event.plugin_id == "feishu" + + @pytest.mark.asyncio + async def test_reload_not_found_raises(self, app_service): + cmd = ReloadCommand(plugin_id="missing") + with pytest.raises(PluginNotFoundException): + await app_service.reload_plugin(cmd) + + @pytest.mark.asyncio + async def test_reload_failure_raises(self, app_service): + class WorkingAdapter(FakeAdapter): + pass + + class BrokenAdapter(FakeAdapter): + def __init__(self, **kwargs) -> None: + raise RuntimeError("boom") + + record = _make_record(plugin_id="feishu", adapter_class=WorkingAdapter) + record.set_config({"token": "old"}) + app_service._registry.register_builtin(record) + await app_service._registry.activate("feishu") + + record.set_adapter_class(BrokenAdapter) + + cmd = ReloadCommand(plugin_id="feishu") + with pytest.raises(PluginReloadException): + await app_service.reload_plugin(cmd) + + @pytest.mark.asyncio + async def test_reload_event_publish_failure_ignored(self, app_service): + publisher = AsyncMock() + publisher.publish.side_effect = RuntimeError("publish failed") + app_service._event_publisher = publisher + + record = _make_record(plugin_id="feishu", adapter_class=FakeAdapter) + record.set_config({"token": "old"}) + app_service._registry.register_builtin(record) + await app_service._registry.activate("feishu") + + cmd = ReloadCommand(plugin_id="feishu") + result = await app_service.reload_plugin(cmd) + assert result.success is True diff --git a/backend/test/unit/channel/application/service/test_session_resolver.py b/backend/test/unit/channel/application/service/test_session_resolver.py index 5662e13b..d9f988b5 100644 --- a/backend/test/unit/channel/application/service/test_session_resolver.py +++ b/backend/test/unit/channel/application/service/test_session_resolver.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Any - import pytest from yuxi.channel.application.service.session_resolver import SessionResolver @@ -14,12 +12,8 @@ class _FakeSessionRepo: self.sessions: dict[str, ChannelSession] = {} self.calls: list[dict] = [] - async def get_or_create( - self, *, channel_type: str, account_id: str, agent_config_id: int - ) -> ChannelSession: - self.calls.append( - {"channel_type": channel_type, "account_id": account_id, "agent_config_id": agent_config_id} - ) + async def get_or_create(self, *, channel_type: str, account_id: str, agent_config_id: int) -> ChannelSession: + self.calls.append({"channel_type": channel_type, "account_id": account_id, "agent_config_id": agent_config_id}) key = f"{channel_type}:{account_id}:{agent_config_id}" if key not in self.sessions: self.sessions[key] = ChannelSession( @@ -46,9 +40,7 @@ class _FakeBindingRepo: self._binding = binding self.calls: list[dict] = [] - async def find_binding( - self, *, channel_type: str, account_id: str, group_id: str - ) -> ChannelBinding | None: + async def find_binding(self, *, channel_type: str, account_id: str, group_id: str) -> ChannelBinding | None: self.calls.append({"channel_type": channel_type, "account_id": account_id, "group_id": group_id}) return self._binding @@ -90,11 +82,6 @@ class _FakeBindingRepo: ) -> tuple[list[ChannelBinding], int]: return [], 0 - async def invalidate_cache( - self, *, channel_type: str, account_id: str, group_id: str - ) -> None: - pass - @pytest.fixture def fake_session_repo() -> _FakeSessionRepo: @@ -107,9 +94,7 @@ def fake_binding_repo() -> _FakeBindingRepo: @pytest.fixture -def resolver( - fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo -) -> SessionResolver: +def resolver(fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo) -> SessionResolver: return SessionResolver( session_repo=fake_session_repo, binding_repo=fake_binding_repo, @@ -341,9 +326,7 @@ async def test_resolve_session_creation_failure( fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo ) -> None: class FailingRepo(_FakeSessionRepo): - async def get_or_create( - self, *, channel_type: str, account_id: str, agent_config_id: int - ) -> ChannelSession: + async def get_or_create(self, *, channel_type: str, account_id: str, agent_config_id: int) -> ChannelSession: raise RuntimeError("db error") resolver = SessionResolver( diff --git a/backend/test/unit/channel/channels/__init__.py b/backend/test/unit/channel/channels/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/channels/__init__.py +++ b/backend/test/unit/channel/channels/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/channels/dingtalk/__init__.py b/backend/test/unit/channel/channels/dingtalk/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/channels/dingtalk/__init__.py +++ b/backend/test/unit/channel/channels/dingtalk/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/channels/dingtalk/test_dingtalk.py b/backend/test/unit/channel/channels/dingtalk/test_dingtalk.py new file mode 100644 index 00000000..1b6e6cb7 --- /dev/null +++ b/backend/test/unit/channel/channels/dingtalk/test_dingtalk.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.channels.dingtalk.adapter import DingTalkAdapter +from yuxi.channel.channels.dingtalk.config import DingTalkConfig +from yuxi.channel.channels.dingtalk.translator import DingTalkTranslator +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +class TestDingTalkTranslator: + def test_translate_p2p_message(self) -> None: + raw = { + "event": { + "message": { + "message_id": "msg-001", + "content": "hello", + "chat_type": "p2p", + }, + "sender": { + "sender_id": {"user_id": "user-123"}, + }, + } + } + msg = DingTalkTranslator.translate_event(raw) + assert msg.message_id == "msg-001" + assert msg.content == "hello" + assert msg.channel_type == ChannelType.DINGTALK + assert msg.sender.id == "user-123" + assert msg.metadata["is_group"] is False + assert msg.metadata["group_id"] == "" + + def test_translate_group_message(self) -> None: + raw = { + "event": { + "message": { + "message_id": "msg-002", + "content": "group hello", + "chat_type": "group", + "chat_id": "chat-456", + }, + "sender": { + "sender_id": {"user_id": "user-789"}, + }, + } + } + msg = DingTalkTranslator.translate_event(raw) + assert msg.metadata["is_group"] is True + assert msg.metadata["group_id"] == "chat-456" + + def test_translate_missing_fields_defaults(self) -> None: + raw = {} + msg = DingTalkTranslator.translate_event(raw) + assert msg.message_id == "" + assert msg.content == "" + assert msg.sender.id == "" + + +class TestDingTalkConfig: + def test_defaults(self) -> None: + config = DingTalkConfig() + assert config.client_id == "" + assert config.client_secret == "" + assert config.ws_enabled is True + + def test_with_values(self) -> None: + config = DingTalkConfig(client_id="id1", client_secret="secret1", ws_enabled=False) + assert config.client_id == "id1" + assert config.client_secret == "secret1" + assert config.ws_enabled is False + + +class TestDingTalkAdapter: + def test_channel_type(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + assert adapter.channel_type == ChannelType.DINGTALK.value + + def test_get_default_config(self) -> None: + config = DingTalkAdapter.get_default_config() + assert "client_id" in config + assert "client_secret" in config + assert "ws_enabled" in config + + @pytest.mark.asyncio + async def test_is_healthy_before_open(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + assert await adapter.is_healthy() is False + + @pytest.mark.asyncio + async def test_is_healthy_after_open(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + await adapter.open() + assert await adapter.is_healthy() is True + + @pytest.mark.asyncio + async def test_close(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + await adapter.open() + await adapter.close() + assert await adapter.is_healthy() is False + + @pytest.mark.asyncio + async def test_receive_message(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + raw = { + "event": { + "message": {"message_id": "m1", "content": "hi", "chat_type": "p2p"}, + "sender": {"sender_id": {"user_id": "u1"}}, + } + } + msg = await adapter.receive_message(raw) + assert msg.message_id == "m1" + assert msg.content == "hi" + + @pytest.mark.asyncio + async def test_send_typing_is_noop(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + await adapter.send_typing("session-1") + + @pytest.mark.asyncio + async def test_send_media_returns_false(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + result = await adapter.send_media("session-1", url="http://x", media_type="image", metadata={}) + assert result is False + + @pytest.mark.asyncio + async def test_send_message_without_token_returns_failure(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + result = await adapter.send_message("session-1", "hello", channel_type="dingtalk", metadata={}) + assert result.success is False + + def test_ws_connection_is_none_when_disabled(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + assert adapter.ws_connection is None + + def test_route_contributor_has_router(self) -> None: + adapter = DingTalkAdapter(client_id="test", client_secret="test", ws_enabled=False) + contributor = adapter.route_contributor + assert contributor.router is not None diff --git a/backend/test/unit/channel/channels/feishu/__init__.py b/backend/test/unit/channel/channels/feishu/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/channels/feishu/__init__.py +++ b/backend/test/unit/channel/channels/feishu/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/channels/feishu/test_feishu.py b/backend/test/unit/channel/channels/feishu/test_feishu.py new file mode 100644 index 00000000..23511888 --- /dev/null +++ b/backend/test/unit/channel/channels/feishu/test_feishu.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.channels.feishu.adapter import FeishuAdapter +from yuxi.channel.channels.feishu.config import FeishuConfig +from yuxi.channel.channels.feishu.translator import FeishuTranslator, _extract_attachments, _extract_text +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +class TestFeishuTranslator: + def test_translate_p2p_message(self) -> None: + raw = { + "event": { + "message": { + "message_id": "msg-001", + "content": '{"text": "hello"}', + "chat_type": "p2p", + }, + "sender": { + "sender_id": {"user_id": "ou-123"}, + }, + } + } + msg = FeishuTranslator.translate_event(raw) + assert msg.message_id == "msg-001" + assert msg.content == "hello" + assert msg.channel_type == ChannelType.FEISHU + assert msg.sender.id == "ou-123" + assert msg.metadata["is_group"] is False + + def test_translate_group_message(self) -> None: + raw = { + "event": { + "message": { + "message_id": "msg-002", + "content": '{"text": "group hello"}', + "chat_type": "group", + "chat_id": "oc-456", + }, + "sender": { + "sender_id": {"user_id": "ou-789"}, + }, + } + } + msg = FeishuTranslator.translate_event(raw) + assert msg.metadata["is_group"] is True + assert msg.metadata["group_id"] == "oc-456" + + def test_translate_missing_fields_defaults(self) -> None: + raw = {} + msg = FeishuTranslator.translate_event(raw) + assert msg.message_id == "" + assert msg.content == "" + + def test_translate_image_attachment(self) -> None: + raw = { + "event": { + "message": { + "message_id": "msg-img", + "content": '{"image_key": "img_key_123"}', + "message_type": "image", + "chat_type": "p2p", + }, + "sender": {"sender_id": {"user_id": "u1"}}, + } + } + msg = FeishuTranslator.translate_event(raw) + assert len(msg.attachments) == 1 + assert msg.attachments[0].url == "img_key_123" + assert msg.attachments[0].media_type == "image" + + def test_translate_file_attachment(self) -> None: + raw = { + "event": { + "message": { + "message_id": "msg-file", + "content": '{"file_key": "fk_123", "file_name": "doc.pdf"}', + "message_type": "file", + "chat_type": "p2p", + }, + "sender": {"sender_id": {"user_id": "u1"}}, + } + } + msg = FeishuTranslator.translate_event(raw) + assert len(msg.attachments) == 1 + assert msg.attachments[0].url == "fk_123" + assert msg.attachments[0].filename == "doc.pdf" + assert msg.attachments[0].media_type == "file" + + +class TestExtractText: + def test_json_text_field(self) -> None: + message = {"content": '{"text": "hello"}'} + assert _extract_text(message) == "hello" + + def test_empty_content(self) -> None: + message = {"content": ""} + assert _extract_text(message) == "" + + def test_invalid_json_returns_raw(self) -> None: + message = {"content": "not-json"} + assert _extract_text(message) == "not-json" + + def test_dict_without_text_returns_raw(self) -> None: + message = {"content": {"other": "value"}} + assert _extract_text(message) == {"other": "value"} + + +class TestExtractAttachments: + def test_image_type(self) -> None: + body = { + "event": { + "message": { + "message_type": "image", + "content": '{"image_key": "ik_1"}', + } + } + } + attachments = _extract_attachments(body) + assert len(attachments) == 1 + assert attachments[0].media_type == "image" + + def test_file_type(self) -> None: + body = { + "event": { + "message": { + "message_type": "file", + "content": '{"file_key": "fk_1", "file_name": "a.txt"}', + } + } + } + attachments = _extract_attachments(body) + assert len(attachments) == 1 + assert attachments[0].media_type == "file" + assert attachments[0].filename == "a.txt" + + def test_text_type_no_attachment(self) -> None: + body = { + "event": { + "message": { + "message_type": "text", + "content": '{"text": "hi"}', + } + } + } + assert _extract_attachments(body) == [] + + def test_invalid_json_no_attachment(self) -> None: + body = { + "event": { + "message": { + "message_type": "image", + "content": "not-json", + } + } + } + assert _extract_attachments(body) == [] + + +class TestFeishuConfig: + def test_defaults(self) -> None: + config = FeishuConfig() + assert config.app_id == "" + assert config.app_secret == "" + assert config.verification_token == "" + assert config.ws_enabled is True + + def test_with_values(self) -> None: + config = FeishuConfig(app_id="id1", app_secret="s1", verification_token="vt1", ws_enabled=False) + assert config.app_id == "id1" + assert config.verification_token == "vt1" + + +class TestFeishuAdapter: + def test_channel_type(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + assert adapter.channel_type == ChannelType.FEISHU.value + + def test_get_default_config(self) -> None: + config = FeishuAdapter.get_default_config() + assert "app_id" in config + assert "app_secret" in config + assert "verification_token" in config + + @pytest.mark.asyncio + async def test_is_healthy_before_open(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + assert await adapter.is_healthy() is False + + @pytest.mark.asyncio + async def test_is_healthy_after_open(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + await adapter.open() + assert await adapter.is_healthy() is True + + @pytest.mark.asyncio + async def test_close(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + await adapter.open() + await adapter.close() + assert await adapter.is_healthy() is False + + @pytest.mark.asyncio + async def test_receive_message(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + raw = { + "event": { + "message": {"message_id": "m1", "content": '{"text": "hi"}', "chat_type": "p2p"}, + "sender": {"sender_id": {"user_id": "u1"}}, + } + } + msg = await adapter.receive_message(raw) + assert msg.message_id == "m1" + + @pytest.mark.asyncio + async def test_send_typing_is_noop(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + await adapter.send_typing("session-1") + + @pytest.mark.asyncio + async def test_send_media_returns_false(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + result = await adapter.send_media("session-1", url="http://x", media_type="image", metadata={}) + assert result is False + + @pytest.mark.asyncio + async def test_send_message_without_client_returns_failure(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + result = await adapter.send_message("session-1", "hello", channel_type="feishu", metadata={}) + assert result.success is False + + def test_ws_connection_is_none_when_disabled(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + assert adapter.ws_connection is None + + def test_route_contributor_has_router(self) -> None: + adapter = FeishuAdapter(app_id="test", app_secret="test", ws_enabled=False) + contributor = adapter.route_contributor + assert contributor.router is not None diff --git a/backend/test/unit/channel/channels/hooks/__init__.py b/backend/test/unit/channel/channels/hooks/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/channels/hooks/__init__.py +++ b/backend/test/unit/channel/channels/hooks/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/channels/hooks/test_hooks.py b/backend/test/unit/channel/channels/hooks/test_hooks.py new file mode 100644 index 00000000..49ea9b17 --- /dev/null +++ b/backend/test/unit/channel/channels/hooks/test_hooks.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.channels.hooks.adapter import HooksAdapter, HOOKS_CAPABILITIES +from yuxi.channel.channels.hooks.config import HookMapping, HooksConfig +from yuxi.channel.channels.hooks.translator import HooksTranslator +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +class TestHookMapping: + def test_defaults(self) -> None: + m = HookMapping(match_path="/test") + assert m.match_path == "/test" + assert m.match_source == "*" + assert m.default_agent_id == 1 + assert m.allowed_agent_ids == [] + assert m.default_session_key == "main" + assert m.allow_request_session_key is False + assert m.allowed_session_key_prefixes == [] + assert m.session_key_strategy == "main" + assert m.deliver is True + assert m.max_body_bytes == 256 * 1024 + assert m.secret is None + + def test_with_values(self) -> None: + m = HookMapping( + match_path="/webhook", + match_source="github", + default_agent_id=5, + secret="s3cret", + max_body_bytes=1024, + ) + assert m.match_source == "github" + assert m.default_agent_id == 5 + assert m.secret == "s3cret" + assert m.max_body_bytes == 1024 + + +class TestHooksConfig: + def test_defaults(self) -> None: + config = HooksConfig() + assert config.mappings == [] + + def test_with_mappings(self) -> None: + m = HookMapping(match_path="/h1") + config = HooksConfig(mappings=[m]) + assert len(config.mappings) == 1 + + +class TestHooksTranslator: + def test_translate_basic(self) -> None: + mapping = HookMapping(match_path="/test", default_agent_id=3) + raw = {"id": "evt-1", "content": "hello"} + msg = HooksTranslator.translate(raw, mapping) + assert msg.message_id == "evt-1" + assert msg.content == "hello" + assert msg.channel_type == ChannelType.HOOKS + assert msg.agent_config_id == 3 + assert msg.metadata["source"] == "hooks" + assert msg.metadata["match_path"] == "/test" + + def test_translate_uses_text_fallback(self) -> None: + mapping = HookMapping(match_path="/test") + raw = {"text": "fallback text"} + msg = HooksTranslator.translate(raw, mapping) + assert msg.content == "fallback text" + + def test_translate_agent_id_from_raw(self) -> None: + mapping = HookMapping(match_path="/test", default_agent_id=1, allowed_agent_ids=[1, 2, 3]) + raw = {"id": "e1", "content": "hi", "agent_id": 2} + msg = HooksTranslator.translate(raw, mapping) + assert msg.agent_config_id == 2 + + def test_translate_agent_id_not_allowed_falls_back(self) -> None: + mapping = HookMapping(match_path="/test", default_agent_id=1, allowed_agent_ids=[1, 2]) + raw = {"id": "e1", "content": "hi", "agent_id": 99} + msg = HooksTranslator.translate(raw, mapping) + assert msg.agent_config_id == 1 + + def test_translate_session_key_from_request(self) -> None: + mapping = HookMapping( + match_path="/test", + allow_request_session_key=True, + allowed_session_key_prefixes=["sess-"], + ) + raw = {"id": "e1", "content": "hi", "session_key": "sess-abc"} + msg = HooksTranslator.translate(raw, mapping) + assert msg.metadata["session_key"] == "sess-abc" + + def test_translate_session_key_prefix_not_allowed(self) -> None: + mapping = HookMapping( + match_path="/test", + allow_request_session_key=True, + allowed_session_key_prefixes=["valid-"], + default_session_key="default", + ) + raw = {"id": "e1", "content": "hi", "session_key": "invalid-abc"} + msg = HooksTranslator.translate(raw, mapping) + assert msg.metadata["session_key"] == "default" + + +class TestHooksAdapter: + def test_channel_type(self) -> None: + adapter = HooksAdapter() + assert adapter.channel_type == ChannelType.HOOKS.value + + def test_capabilities(self) -> None: + assert HOOKS_CAPABILITIES.media is False + assert HOOKS_CAPABILITIES.dm is True + assert HOOKS_CAPABILITIES.max_text_length == 32768 + + def test_get_default_config(self) -> None: + config = HooksAdapter.get_default_config() + assert "mappings" in config + + def test_match_hook_exact(self) -> None: + adapter = HooksAdapter( + mappings=[ + {"match_path": "/webhook", "match_source": "github"}, + ] + ) + result = adapter.match_hook("/webhook", "github") + assert result is not None + assert result.match_path == "/webhook" + + def test_match_hook_wildcard(self) -> None: + adapter = HooksAdapter( + mappings=[ + {"match_path": "/hook1"}, + ] + ) + result = adapter.match_hook("/hook1", "any-source") + assert result is not None + + def test_match_hook_not_found(self) -> None: + adapter = HooksAdapter( + mappings=[ + {"match_path": "/hook1"}, + ] + ) + result = adapter.match_hook("/nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_is_healthy_no_mappings(self) -> None: + adapter = HooksAdapter() + await adapter.open() + assert await adapter.is_healthy() is False + + @pytest.mark.asyncio + async def test_is_healthy_with_mappings(self) -> None: + adapter = HooksAdapter(mappings=[{"match_path": "/h1"}]) + await adapter.open() + assert await adapter.is_healthy() is True + + @pytest.mark.asyncio + async def test_close(self) -> None: + adapter = HooksAdapter(mappings=[{"match_path": "/h1"}]) + await adapter.open() + await adapter.close() + assert await adapter.is_healthy() is False + + @pytest.mark.asyncio + async def test_receive_message(self) -> None: + adapter = HooksAdapter(mappings=[{"match_path": "/test"}]) + raw = {"id": "e1", "content": "hi", "match_path": "/test"} + msg = await adapter.receive_message(raw) + assert msg.message_id == "e1" + + @pytest.mark.asyncio + async def test_receive_message_no_mapping_raises(self) -> None: + adapter = HooksAdapter(mappings=[{"match_path": "/other"}]) + raw = {"id": "e1", "content": "hi", "match_path": "/nonexistent"} + with pytest.raises(ValueError, match="no hook mapping"): + await adapter.receive_message(raw) + + @pytest.mark.asyncio + async def test_send_message_always_succeeds(self) -> None: + adapter = HooksAdapter() + result = await adapter.send_message("sess-1", "hello", channel_type="hooks", metadata={}) + assert result.success is True + + @pytest.mark.asyncio + async def test_send_media_returns_true(self) -> None: + adapter = HooksAdapter() + result = await adapter.send_media("sess-1", url="http://x", media_type="image", metadata={}) + assert result is True + + def test_ws_connection_is_none(self) -> None: + adapter = HooksAdapter() + assert adapter.ws_connection is None + + def test_route_contributor_has_router(self) -> None: + adapter = HooksAdapter() + contributor = adapter.route_contributor + assert contributor.router is not None diff --git a/backend/test/unit/channel/channels/test_registry.py b/backend/test/unit/channel/channels/test_registry.py new file mode 100644 index 00000000..e0961970 --- /dev/null +++ b/backend/test/unit/channel/channels/test_registry.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from yuxi.channel.channels._registry import get_compat_registry, register_channel + + +class TestCompatRegistry: + def test_register_and_get(self) -> None: + class FakeAdapter: + pass + + register_channel("test_channel", FakeAdapter) + registry = get_compat_registry() + assert "test_channel" in registry + assert registry["test_channel"] is FakeAdapter + + def test_get_compat_registry_returns_copy(self) -> None: + r1 = get_compat_registry() + r2 = get_compat_registry() + assert r1 is not r2 + assert r1 == r2 diff --git a/backend/test/unit/channel/channels/web/__init__.py b/backend/test/unit/channel/channels/web/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/channels/web/__init__.py +++ b/backend/test/unit/channel/channels/web/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/channels/web/test_web.py b/backend/test/unit/channel/channels/web/test_web.py new file mode 100644 index 00000000..09123ce7 --- /dev/null +++ b/backend/test/unit/channel/channels/web/test_web.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from yuxi.channel.channels.web.adapter import WebAdapter +from yuxi.channel.channels.web.config import WebConfig +from yuxi.channel.channels.web.outbound import WEB_CAPABILITIES, WebOutbound +from yuxi.channel.channels.web.translator import WebTranslator +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +class TestWebTranslator: + def test_translate_message(self) -> None: + raw = { + "id": "msg-001", + "sender_id": "user-1", + "sender_name": "Alice", + "content": "hello world", + "metadata": {"key": "value"}, + } + msg = WebTranslator.translate_message(raw) + assert msg.message_id == "msg-001" + assert msg.channel_type == ChannelType.WEB + assert msg.sender.id == "user-1" + assert msg.sender.name == "Alice" + assert msg.content == "hello world" + assert msg.metadata == {"key": "value"} + + def test_translate_message_defaults(self) -> None: + raw = {} + msg = WebTranslator.translate_message(raw) + assert msg.message_id == "" + assert msg.content == "" + assert msg.sender.id == "" + assert msg.metadata == {} + + +class TestWebConfig: + def test_defaults(self) -> None: + config = WebConfig() + assert config.max_connections == 1000 + + def test_with_values(self) -> None: + config = WebConfig(max_connections=500) + assert config.max_connections == 500 + + +class TestWebOutbound: + @pytest.mark.asyncio + async def test_send_text_without_sse_returns_false(self) -> None: + outbound = WebOutbound() + result = await outbound.send_text("sess-1", "hello") + assert result is False + + @pytest.mark.asyncio + async def test_send_text_with_sse_push(self) -> None: + sse = AsyncMock() + sse.push_event = AsyncMock(return_value=True) + outbound = WebOutbound(sse_push=sse) + result = await outbound.send_text("sess-1", "hello", metadata={"channel_type": "web"}) + assert result is True + sse.push_event.assert_called_once() + event = sse.push_event.call_args[0][1] + assert event["type"] == "assistant_message" + assert event["content"] == "hello" + + @pytest.mark.asyncio + async def test_send_text_sse_push_failure(self) -> None: + sse = AsyncMock() + sse.push_event = AsyncMock(return_value=False) + outbound = WebOutbound(sse_push=sse) + result = await outbound.send_text("sess-1", "hello") + assert result is False + + @pytest.mark.asyncio + async def test_send_typing_without_sse(self) -> None: + outbound = WebOutbound() + await outbound.send_typing("sess-1") + + @pytest.mark.asyncio + async def test_send_typing_with_sse(self) -> None: + sse = AsyncMock() + sse.push_event = AsyncMock(return_value=True) + outbound = WebOutbound(sse_push=sse) + await outbound.send_typing("sess-1") + sse.push_event.assert_called_once() + + @pytest.mark.asyncio + async def test_send_media_without_sse_returns_false(self) -> None: + outbound = WebOutbound() + result = await outbound.send_media("sess-1", url="http://x", media_type="image") + assert result is False + + @pytest.mark.asyncio + async def test_send_media_with_sse(self) -> None: + sse = AsyncMock() + sse.push_event = AsyncMock(return_value=True) + outbound = WebOutbound(sse_push=sse) + result = await outbound.send_media("sess-1", url="http://x", media_type="image") + assert result is True + + +class TestWebAdapter: + def test_channel_type(self) -> None: + adapter = WebAdapter() + assert adapter.channel_type == ChannelType.WEB.value + + def test_capabilities(self) -> None: + assert WEB_CAPABILITIES.media is True + assert WEB_CAPABILITIES.streaming is True + assert WEB_CAPABILITIES.max_text_length == 32768 + + def test_get_default_config(self) -> None: + config = WebAdapter.get_default_config() + assert "max_connections" in config + + @pytest.mark.asyncio + async def test_is_healthy_before_open(self) -> None: + adapter = WebAdapter() + assert await adapter.is_healthy() is False + + @pytest.mark.asyncio + async def test_is_healthy_after_open(self) -> None: + adapter = WebAdapter() + await adapter.open() + assert await adapter.is_healthy() is True + + @pytest.mark.asyncio + async def test_close(self) -> None: + adapter = WebAdapter() + await adapter.open() + await adapter.close() + assert await adapter.is_healthy() is False + + @pytest.mark.asyncio + async def test_receive_message(self) -> None: + adapter = WebAdapter() + raw = {"id": "m1", "content": "hi", "sender_id": "u1"} + msg = await adapter.receive_message(raw) + assert msg.message_id == "m1" + + @pytest.mark.asyncio + async def test_send_message_without_sse_returns_failure(self) -> None: + adapter = WebAdapter() + result = await adapter.send_message("sess-1", "hello", channel_type="web", metadata={}) + assert result.success is False + + @pytest.mark.asyncio + async def test_send_message_with_sse(self) -> None: + sse = AsyncMock() + sse.push_event = AsyncMock(return_value=True) + adapter = WebAdapter(sse_push=sse) + await adapter.open() + result = await adapter.send_message("sess-1", "hello", channel_type="web", metadata={}) + assert result.success is True + + @pytest.mark.asyncio + async def test_send_typing(self) -> None: + sse = AsyncMock() + sse.push_event = AsyncMock(return_value=True) + adapter = WebAdapter(sse_push=sse) + await adapter.send_typing("sess-1") + + def test_ws_connection_is_none(self) -> None: + adapter = WebAdapter() + assert adapter.ws_connection is None + + def test_route_contributor_has_router(self) -> None: + adapter = WebAdapter() + contributor = adapter.route_contributor + assert contributor.router is not None diff --git a/backend/test/unit/channel/container/__init__.py b/backend/test/unit/channel/container/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/container/__init__.py +++ b/backend/test/unit/channel/container/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/container/test_container_factory_build_workers.py b/backend/test/unit/channel/container/test_container_factory_build_workers.py index 1d49a69c..2a3edc61 100644 --- a/backend/test/unit/channel/container/test_container_factory_build_workers.py +++ b/backend/test/unit/channel/container/test_container_factory_build_workers.py @@ -44,9 +44,7 @@ class TestBuildWorkers: with patch("yuxi.channel.container.pg_manager") as mock_pg: mock_pg.get_async_session_context = MagicMock() - workers = ChannelContainerFactory._build_workers( - infra, adapters, None, config, pipeline, redis - ) + workers = ChannelContainerFactory._build_workers(infra, adapters, None, config, pipeline, redis) assert isinstance(workers, _WorkerBundle) assert workers.dispatch_service is not None @@ -67,9 +65,7 @@ class TestBuildWorkers: with patch("yuxi.channel.container.pg_manager") as mock_pg: mock_pg.get_async_session_context = MagicMock() - workers = ChannelContainerFactory._build_workers( - infra, adapters, agent_port, config, pipeline, redis - ) + workers = ChannelContainerFactory._build_workers(infra, adapters, agent_port, config, pipeline, redis) assert workers.dispatch_service is not None diff --git a/backend/test/unit/channel/container/test_container_startup.py b/backend/test/unit/channel/container/test_container_startup.py index 47872ee5..39e73f7a 100644 --- a/backend/test/unit/channel/container/test_container_startup.py +++ b/backend/test/unit/channel/container/test_container_startup.py @@ -16,7 +16,9 @@ class TestInitChannel: mock_container = MagicMock() mock_container.shutdown = AsyncMock() - with patch("yuxi.channel.startup.ChannelContainerFactory.create", new_callable=AsyncMock, return_value=mock_container): + with patch( + "yuxi.channel.startup.ChannelContainerFactory.create", new_callable=AsyncMock, return_value=mock_container + ): container = await init_channel( redis_url="redis://localhost:6379/0", config_yaml_path=str(config_file), @@ -34,7 +36,9 @@ class TestShutdownChannel: mock_container = MagicMock() mock_container.shutdown = AsyncMock() - with patch("yuxi.channel.startup.ChannelContainerFactory.create", new_callable=AsyncMock, return_value=mock_container): + with patch( + "yuxi.channel.startup.ChannelContainerFactory.create", new_callable=AsyncMock, return_value=mock_container + ): await init_channel(redis_url="redis://localhost:6379/0") await shutdown_channel() diff --git a/backend/test/unit/channel/domain/__init__.py b/backend/test/unit/channel/domain/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/__init__.py +++ b/backend/test/unit/channel/domain/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/event/__init__.py b/backend/test/unit/channel/domain/event/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/event/__init__.py +++ b/backend/test/unit/channel/domain/event/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/event/test_plugin_events.py b/backend/test/unit/channel/domain/event/test_plugin_events.py new file mode 100644 index 00000000..54abffb2 --- /dev/null +++ b/backend/test/unit/channel/domain/event/test_plugin_events.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from yuxi.channel.domain.event.plugin.plugin_activated_event import PluginActivatedEvent +from yuxi.channel.domain.event.plugin.plugin_deactivated_event import PluginDeactivatedEvent +from yuxi.channel.domain.event.plugin.plugin_registered_event import PluginRegisteredEvent +from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent + + +def test_plugin_activated_event() -> None: + event = PluginActivatedEvent(plugin_id="web", channel_type="web") + assert event.plugin_id == "web" + assert event.channel_type == "web" + + +def test_plugin_activated_event_is_frozen() -> None: + event = PluginActivatedEvent(plugin_id="web", channel_type="web") + assert event.__dataclass_fields__ is not None + + +def test_plugin_deactivated_event() -> None: + event = PluginDeactivatedEvent(plugin_id="feishu", channel_type="feishu") + assert event.plugin_id == "feishu" + + +def test_plugin_registered_event() -> None: + event = PluginRegisteredEvent(plugin_id="hooks", channel_type="hooks") + assert event.plugin_id == "hooks" + + +def test_plugin_reloaded_event() -> None: + event = PluginReloadedEvent( + plugin_id="web", + old_version="1.0", + new_version="2.0", + channel_type="web", + ) + assert event.old_version == "1.0" + assert event.new_version == "2.0" diff --git a/backend/test/unit/channel/domain/exception/__init__.py b/backend/test/unit/channel/domain/exception/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/exception/__init__.py +++ b/backend/test/unit/channel/domain/exception/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/exception/test_plugin_exception.py b/backend/test/unit/channel/domain/exception/test_plugin_exception.py new file mode 100644 index 00000000..bdde5ea2 --- /dev/null +++ b/backend/test/unit/channel/domain/exception/test_plugin_exception.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.exception.plugin_exception import ( + ConfigNotFoundException, + ConfigValidationException, + DuplicatePluginException, + InvalidManifestException, + PluginAdapterNotFoundException, + PluginAlreadyActivatedException, + PluginAlreadyRegisteredException, + PluginException, + PluginNotFoundException, + PluginNotConfiguredException, + PluginRegistrationException, + PluginReloadException, + RegistryLockedException, + SnapshotException, +) + + +class TestPluginExceptionHierarchy: + def test_base_exception(self): + exc = PluginException("test error", "test_id") + assert str(exc) == "test error" + assert exc.plugin_id == "test_id" + + def test_duplicate_plugin(self): + exc = DuplicatePluginException("feishu") + assert "feishu" in str(exc) + assert exc.plugin_id == "feishu" + + def test_plugin_not_found(self): + exc = PluginNotFoundException("missing") + assert "missing" in str(exc) + + def test_already_registered(self): + exc = PluginAlreadyRegisteredException("web") + assert "web" in str(exc) + + def test_already_activated(self): + exc = PluginAlreadyActivatedException("hooks") + assert "hooks" in str(exc) + + def test_not_configured(self): + exc = PluginNotConfiguredException("dingtalk") + assert "dingtalk" in str(exc) + + def test_adapter_not_found(self): + exc = PluginAdapterNotFoundException("custom") + assert "custom" in str(exc) + + def test_registration_failed(self): + exc = PluginRegistrationException("broken") + assert "broken" in str(exc) + + def test_reload_failed(self): + exc = PluginReloadException("stuck") + assert "stuck" in str(exc) + + def test_invalid_manifest(self): + exc = InvalidManifestException("bad yaml") + assert "bad yaml" in str(exc) + + def test_registry_locked(self): + exc = RegistryLockedException() + assert "locked" in str(exc) + + def test_snapshot_exception(self): + exc = SnapshotException() + assert "failed" in str(exc) + + def test_config_validation(self): + exc = ConfigValidationException("test", "missing field") + assert "test" in str(exc) + assert exc.plugin_id == "test" + + def test_config_not_found(self): + exc = ConfigNotFoundException("test") + assert "test" in str(exc) + + def test_all_inherit_from_plugin_exception(self): + exceptions = [ + DuplicatePluginException("a"), + PluginNotFoundException("a"), + PluginAlreadyRegisteredException("a"), + PluginAlreadyActivatedException("a"), + PluginNotConfiguredException("a"), + PluginAdapterNotFoundException("a"), + PluginRegistrationException("a"), + PluginReloadException("a"), + InvalidManifestException("a"), + RegistryLockedException(), + SnapshotException(), + ConfigValidationException("a", "a"), + ConfigNotFoundException("a"), + ] + for exc in exceptions: + assert isinstance(exc, PluginException) diff --git a/backend/test/unit/channel/domain/middleware/__init__.py b/backend/test/unit/channel/domain/middleware/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/middleware/__init__.py +++ b/backend/test/unit/channel/domain/middleware/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/__init__.py b/backend/test/unit/channel/domain/model/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/model/__init__.py +++ b/backend/test/unit/channel/domain/model/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/binding/__init__.py b/backend/test/unit/channel/domain/model/binding/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/model/binding/__init__.py +++ b/backend/test/unit/channel/domain/model/binding/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/message/__init__.py b/backend/test/unit/channel/domain/model/message/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/model/message/__init__.py +++ b/backend/test/unit/channel/domain/model/message/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/outbox/__init__.py b/backend/test/unit/channel/domain/model/outbox/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/model/outbox/__init__.py +++ b/backend/test/unit/channel/domain/model/outbox/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/plugin_registry/__init__.py b/backend/test/unit/channel/domain/model/plugin_registry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_models.py b/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_models.py new file mode 100644 index 00000000..1f195070 --- /dev/null +++ b/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_models.py @@ -0,0 +1,356 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import ( + PluginAuditLog, + PluginConfigHistory, + PluginPersistentRecord, +) +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_registration_mode import PluginRegistrationMode +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry, PluginSpec +from yuxi.channel.domain.model.plugin_registry.plugin_registry_factory import PluginRegistryFactory +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + + +class TestPluginManifest: + def test_valid_manifest(self) -> None: + m = PluginManifest( + id="web", + name="Web", + version="1.0.0", + channel_type="web", + capabilities=ChannelCapabilities(), + ) + assert m.id == "web" + assert m.version == "1.0.0" + + def test_empty_version_defaults_to_000(self) -> None: + m = PluginManifest( + id="test", + name="Test", + version="", + channel_type="test", + capabilities=ChannelCapabilities(), + ) + assert m.version == "0.0.0" + + def test_empty_id_raises(self) -> None: + with pytest.raises(InvalidManifestException): + PluginManifest( + id="", + name="Test", + version="1.0", + channel_type="test", + capabilities=ChannelCapabilities(), + ) + + def test_empty_channel_type_raises(self) -> None: + with pytest.raises(InvalidManifestException): + PluginManifest( + id="test", + name="Test", + version="1.0", + channel_type="", + capabilities=ChannelCapabilities(), + ) + + def test_frozen(self) -> None: + m = PluginManifest(id="x", name="X", version="1.0", channel_type="x", capabilities=ChannelCapabilities()) + with pytest.raises(AttributeError): + m.id = "y" + + +class TestPluginPersistentRecord: + def test_defaults(self) -> None: + r = PluginPersistentRecord() + assert r.id is None + assert r.plugin_id == "" + assert r.status == "discovered" + assert r.is_enabled is True + + def test_with_values(self) -> None: + r = PluginPersistentRecord(plugin_id="web", name="Web", version="1.0") + assert r.plugin_id == "web" + + +class TestPluginConfigHistory: + def test_defaults(self) -> None: + h = PluginConfigHistory() + assert h.plugin_id == "" + assert h.config == {} + + +class TestPluginAuditLog: + def test_defaults(self) -> None: + log = PluginAuditLog() + assert log.action == "" + assert log.plugin_id == "" + + +class TestPluginRecordId: + def test_generate_unique(self) -> None: + id1 = PluginRecordId.generate() + id2 = PluginRecordId.generate() + assert id1.value != id2.value + + def test_default_value(self) -> None: + rid = PluginRecordId() + assert len(rid.value) > 0 + + +class TestPluginRecord: + def _make_record(self, **overrides) -> PluginRecord: + manifest = PluginManifest( + id="test", name="Test", version="1.0", channel_type="test", capabilities=ChannelCapabilities() + ) + source = PluginSource(origin=PluginOrigin.BUNDLED, root_dir=".", entry_file="a.py") + defaults = dict( + record_id=PluginRecordId.generate(), + plugin_id="test", + name="Test", + version="1.0", + channel_type="test", + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + ) + defaults.update(overrides) + return PluginRecord(**defaults) + + def test_is_configured_no_schema(self) -> None: + record = self._make_record() + assert record.is_configured is True + + def test_is_configured_with_required_keys(self) -> None: + record = self._make_record(config_schema={"required": ["app_id"]}, config={"app_id": "x"}) + assert record.is_configured is True + + def test_is_not_configured_missing_required(self) -> None: + record = self._make_record(config_schema={"required": ["app_id"]}, config={}) + assert record.is_configured is False + + def test_set_config(self) -> None: + record = self._make_record() + record.set_config({"key": "value"}) + assert record.config == {"key": "value"} + + def test_register_changes_status(self) -> None: + record = self._make_record() + assert record.status == PluginStatus.DISCOVERED + record.register(api=None) + assert record.status == PluginStatus.REGISTERED + assert record.registered_at is not None + + def test_register_from_registered_raises(self) -> None: + from yuxi.channel.domain.exception.plugin_exception import PluginAlreadyRegisteredException + + record = self._make_record() + record.register(api=None) + with pytest.raises(PluginAlreadyRegisteredException): + record.register(api=None) + + def test_mark_error(self) -> None: + record = self._make_record() + record.mark_error("something broke") + assert record.status == PluginStatus.ERROR + assert record.error == "something broke" + + def test_to_snapshot(self) -> None: + record = self._make_record() + snap = record.to_snapshot() + assert snap["plugin_id"] == "test" + assert "capabilities" in snap + assert snap["status"] == "discovered" + + +class TestPluginRegistrationMode: + def test_values(self) -> None: + assert PluginRegistrationMode.FULL == "full" + assert PluginRegistrationMode.DISCOVERY == "discovery" + assert PluginRegistrationMode.SETUP_ONLY == "setup_only" + assert PluginRegistrationMode.CLI_METADATA == "cli_metadata" + + +class TestPluginSource: + def test_origin_values(self) -> None: + assert PluginOrigin.BUNDLED == "bundled" + assert PluginOrigin.WORKSPACE == "workspace" + assert PluginOrigin.INSTALLED == "installed" + + def test_source_frozen(self) -> None: + source = PluginSource(origin=PluginOrigin.BUNDLED, root_dir=".", entry_file="a.py") + with pytest.raises(AttributeError): + source.origin = PluginOrigin.WORKSPACE + + def test_source_enabled_default(self) -> None: + source = PluginSource(origin=PluginOrigin.BUNDLED, root_dir=".", entry_file="a.py") + assert source.enabled is True + + +class TestPluginStatus: + def test_values(self) -> None: + assert PluginStatus.DISCOVERED == "discovered" + assert PluginStatus.REGISTERED == "registered" + assert PluginStatus.CONFIGURED == "configured" + assert PluginStatus.ACTIVE == "active" + assert PluginStatus.ERROR == "error" + assert PluginStatus.DISABLED == "disabled" + + +class TestPluginSpec: + def _make_record(self, status=PluginStatus.ACTIVE, origin=PluginOrigin.BUNDLED) -> PluginRecord: + manifest = PluginManifest(id="t", name="T", version="1.0", channel_type="t", capabilities=ChannelCapabilities()) + source = PluginSource(origin=origin, root_dir=".", entry_file="a.py") + return PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id="t", + name="T", + version="1.0", + channel_type="t", + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + status=status, + ) + + def test_satisfied_by_origin(self) -> None: + spec = PluginSpec(origin="bundled") + record = self._make_record() + assert spec.is_satisfied_by(record) is True + + def test_not_satisfied_by_origin(self) -> None: + spec = PluginSpec(origin="workspace") + record = self._make_record() + assert spec.is_satisfied_by(record) is False + + def test_satisfied_by_status(self) -> None: + spec = PluginSpec(status=PluginStatus.ACTIVE) + record = self._make_record(status=PluginStatus.ACTIVE) + assert spec.is_satisfied_by(record) is True + + def test_not_satisfied_by_status(self) -> None: + spec = PluginSpec(status=PluginStatus.DISABLED) + record = self._make_record(status=PluginStatus.ACTIVE) + assert spec.is_satisfied_by(record) is False + + def test_no_filter_matches_all(self) -> None: + spec = PluginSpec() + record = self._make_record() + assert spec.is_satisfied_by(record) is True + + +class TestPluginRegistry: + def _make_record(self, plugin_id="test") -> PluginRecord: + manifest = PluginManifest( + id=plugin_id, name=plugin_id, version="1.0", channel_type=plugin_id, capabilities=ChannelCapabilities() + ) + source = PluginSource(origin=PluginOrigin.BUNDLED, root_dir=".", entry_file="a.py") + return PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id=plugin_id, + name=plugin_id, + version="1.0", + channel_type=plugin_id, + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + ) + + @pytest.mark.asyncio + async def test_register_and_get(self) -> None: + registry = PluginRegistry() + record = self._make_record("web") + await registry.register(record) + assert registry.get_record("web") is record + + @pytest.mark.asyncio + async def test_register_duplicate_raises(self) -> None: + from yuxi.channel.domain.exception.plugin_exception import DuplicatePluginException + + registry = PluginRegistry() + await registry.register(self._make_record("web")) + with pytest.raises(DuplicatePluginException): + await registry.register(self._make_record("web")) + + def test_register_builtin_idempotent(self) -> None: + registry = PluginRegistry() + record = self._make_record("web") + registry.register_builtin(record) + registry.register_builtin(record) + assert registry.get_record("web") is record + + @pytest.mark.asyncio + async def test_get_nonexistent_returns_none(self) -> None: + registry = PluginRegistry() + assert registry.get_record("missing") is None + + @pytest.mark.asyncio + async def test_list_records_with_spec(self) -> None: + registry = PluginRegistry() + await registry.register(self._make_record("web")) + await registry.register(self._make_record("feishu")) + spec = PluginSpec(origin="bundled") + records = registry.list_records(spec) + assert len(records) == 2 + + @pytest.mark.asyncio + async def test_list_records_no_spec(self) -> None: + registry = PluginRegistry() + await registry.register(self._make_record("web")) + records = registry.list_records() + assert len(records) == 1 + + @pytest.mark.asyncio + async def test_deactivate_nonexistent_raises(self) -> None: + from yuxi.channel.domain.exception.plugin_exception import PluginNotFoundException + + registry = PluginRegistry() + with pytest.raises(PluginNotFoundException): + await registry.deactivate("missing") + + def test_snapshot_and_restore(self) -> None: + registry = PluginRegistry() + record = self._make_record("web") + registry.register_builtin(record) + snap = registry.snapshot() + registry._plugins = {} + assert registry.get_record("web") is None + registry.restore(snap) + assert registry.get_record("web") is record + + +class TestPluginRegistryFactory: + def test_create_from_manifest(self) -> None: + manifest = PluginManifest( + id="web", name="Web", version="1.0", channel_type="web", capabilities=ChannelCapabilities() + ) + source = PluginSource(origin=PluginOrigin.BUNDLED, root_dir=".", entry_file="a.py") + record = PluginRegistryFactory.create_from_manifest(manifest, source) + assert record.plugin_id == "web" + assert record.version == "1.0" + + def test_create_from_manifest_with_adapter(self) -> None: + class FakeAdapter: + pass + + manifest = PluginManifest(id="x", name="X", version="1.0", channel_type="x", capabilities=ChannelCapabilities()) + source = PluginSource(origin=PluginOrigin.BUNDLED, root_dir=".", entry_file="a.py") + record = PluginRegistryFactory.create_from_manifest(manifest, source, adapter_class=FakeAdapter) + assert record.adapter_class is FakeAdapter + + def test_create_builtin(self) -> None: + class FakeAdapter: + @staticmethod + def get_default_config(): + return {"key": "value"} + + record = PluginRegistryFactory.create_builtin("hooks", "Hooks", "hooks", FakeAdapter) + assert record.plugin_id == "hooks" + assert record.adapter_class is FakeAdapter + assert record.source.origin == PluginOrigin.BUNDLED diff --git a/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_registry.py b/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_registry.py new file mode 100644 index 00000000..8d5d4a07 --- /dev/null +++ b/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_registry.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import asyncio +from datetime import UTC, datetime + +import pytest + +from yuxi.channel.domain.exception.plugin_exception import ( + DuplicatePluginException, + PluginAlreadyActivatedException, + PluginAlreadyRegisteredException, + PluginNotConfiguredException, + PluginNotFoundException, +) +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry, PluginSpec +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + + +def _make_record( + plugin_id: str = "test", + channel_type: str = "test", + status: PluginStatus = PluginStatus.DISCOVERED, + config_schema: dict | None = None, +) -> PluginRecord: + manifest = PluginManifest( + id=plugin_id, + name=plugin_id.capitalize(), + version="1.0.0", + channel_type=channel_type, + capabilities=ChannelCapabilities(), + ) + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir=f"channels/{channel_type}", + entry_file=f"channels/{channel_type}/adapter.py", + ) + return PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id=plugin_id, + name=plugin_id.capitalize(), + version="1.0.0", + channel_type=channel_type, + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + config_schema=config_schema, + status=status, + ) + + +class TestPluginRecord: + def test_is_configured_no_schema(self): + record = _make_record() + assert record.is_configured is True + + def test_is_configured_with_required_keys(self): + record = _make_record(config_schema={"required": ["app_id", "app_secret"]}) + assert record.is_configured is False + record.set_config({"app_id": "x", "app_secret": "y"}) + assert record.is_configured is True + + def test_is_configured_partial(self): + record = _make_record(config_schema={"required": ["app_id", "app_secret"]}) + record.set_config({"app_id": "x"}) + assert record.is_configured is False + + def test_register_changes_status(self): + record = _make_record() + assert record.status == PluginStatus.DISCOVERED + record.register(api=None) + assert record.status == PluginStatus.REGISTERED + assert record.registered_at is not None + + def test_register_twice_raises(self): + record = _make_record() + record.register(api=None) + with pytest.raises(PluginAlreadyRegisteredException): + record.register(api=None) + + def test_mark_error(self): + record = _make_record() + record.mark_error("boom") + assert record.status == PluginStatus.ERROR + assert record.error == "boom" + + def test_to_snapshot(self): + record = _make_record(plugin_id="feishu", channel_type="feishu") + snap = record.to_snapshot() + assert snap["plugin_id"] == "feishu" + assert snap["channel_type"] == "feishu" + assert snap["status"] == "discovered" + + +class TestPluginRegistry: + @pytest.fixture + def registry(self): + return PluginRegistry() + + def test_register_builtin(self, registry): + record = _make_record(plugin_id="web") + registry.register_builtin(record) + assert registry.get_record("web") is record + + def test_register_builtin_idempotent(self, registry): + record1 = _make_record(plugin_id="web") + record2 = _make_record(plugin_id="web") + registry.register_builtin(record1) + registry.register_builtin(record2) + assert registry.get_record("web") is record1 + + @pytest.mark.asyncio + async def test_async_register(self, registry): + record = _make_record(plugin_id="feishu") + await registry.register(record) + assert registry.get_record("feishu") is record + assert record.status == PluginStatus.REGISTERED + + @pytest.mark.asyncio + async def test_async_register_duplicate_raises(self, registry): + record = _make_record(plugin_id="feishu") + await registry.register(record) + with pytest.raises(DuplicatePluginException): + await registry.register(_make_record(plugin_id="feishu")) + + @pytest.mark.asyncio + async def test_activate_not_configured_raises(self, registry): + record = _make_record(plugin_id="feishu", config_schema={"required": ["app_id"]}) + registry.register_builtin(record) + with pytest.raises(PluginNotConfiguredException): + await registry.activate("feishu") + + @pytest.mark.asyncio + async def test_deactivate_nonexistent_raises(self, registry): + with pytest.raises(PluginNotFoundException): + await registry.deactivate("nonexistent") + + def test_list_records(self, registry): + registry.register_builtin(_make_record(plugin_id="a")) + registry.register_builtin(_make_record(plugin_id="b")) + records = registry.list_records() + assert len(records) == 2 + + def test_list_records_with_spec(self, registry): + r1 = _make_record(plugin_id="a") + r2 = _make_record(plugin_id="b") + registry.register_builtin(r1) + registry.register_builtin(r2) + r1.status = PluginStatus.ACTIVE + spec = PluginSpec(status=PluginStatus.ACTIVE) + active = registry.list_records(spec) + assert len(active) == 1 + assert active[0].plugin_id == "a" + + def test_snapshot_restore(self, registry): + registry.register_builtin(_make_record(plugin_id="before")) + snapshot = registry.snapshot() + registry.register_builtin(_make_record(plugin_id="after")) + assert len(registry.list_records()) == 2 + registry.restore(snapshot) + assert len(registry.list_records()) == 1 + assert registry.get_record("before") is not None + assert registry.get_record("after") is None + + def test_list_adapters_empty(self, registry): + adapters = registry.list_adapters() + assert adapters == {} + + def test_get_adapter_none(self, registry): + assert registry.get_adapter("nonexistent") is None + + +class TestPluginManifest: + def test_valid_manifest(self): + m = PluginManifest( + id="test", + name="Test", + version="1.0.0", + channel_type="test", + capabilities=ChannelCapabilities(), + ) + assert m.id == "test" + + def test_empty_id_raises(self): + from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException + + with pytest.raises(InvalidManifestException): + PluginManifest( + id="", + name="Test", + version="1.0.0", + channel_type="test", + capabilities=ChannelCapabilities(), + ) + + def test_empty_channel_type_raises(self): + from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException + + with pytest.raises(InvalidManifestException): + PluginManifest( + id="test", + name="Test", + version="1.0.0", + channel_type="", + capabilities=ChannelCapabilities(), + ) + + def test_empty_version_defaults(self): + m = PluginManifest( + id="test", + name="Test", + version="", + channel_type="test", + capabilities=ChannelCapabilities(), + ) + assert m.version == "0.0.0" + + +class TestPluginSource: + def test_defaults(self): + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="channels/test", + entry_file="channels/test/adapter.py", + ) + assert source.enabled is True + assert source.origin == PluginOrigin.BUNDLED + + +class TestPluginStatus: + def test_values(self): + assert PluginStatus.DISCOVERED == "discovered" + assert PluginStatus.REGISTERED == "registered" + assert PluginStatus.CONFIGURED == "configured" + assert PluginStatus.ACTIVE == "active" + assert PluginStatus.ERROR == "error" + assert PluginStatus.DISABLED == "disabled" diff --git a/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_registry_advanced.py b/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_registry_advanced.py new file mode 100644 index 00000000..662a7934 --- /dev/null +++ b/backend/test/unit/channel/domain/model/plugin_registry/test_plugin_registry_advanced.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import asyncio + +import pytest + +from yuxi.channel.domain.exception.plugin_exception import ( + PluginAlreadyActivatedException, + PluginNotConfiguredException, + PluginNotFoundException, +) +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry, RegistrySnapshot +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + + +class FakeAdapter: + def __init__(self, **kwargs) -> None: + self._open = False + self._closed = False + self.config = kwargs + + async def open(self) -> None: + self._open = True + + async def close(self) -> None: + self._closed = True + + @property + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities() + + @property + def channel_type(self) -> str: + return "fake" + + @classmethod + def get_default_config(cls) -> dict: + return {} + + +def _make_record( + plugin_id: str = "test", + channel_type: str = "test", + status: PluginStatus = PluginStatus.DISCOVERED, + config_schema: dict | None = None, + adapter_class: type | None = None, +) -> PluginRecord: + manifest = PluginManifest( + id=plugin_id, + name=plugin_id.capitalize(), + version="1.0.0", + channel_type=channel_type, + capabilities=ChannelCapabilities(), + ) + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir=f"channels/{channel_type}", + entry_file=f"channels/{channel_type}/adapter.py", + ) + record = PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id=plugin_id, + name=plugin_id.capitalize(), + version="1.0.0", + channel_type=channel_type, + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + config_schema=config_schema, + status=status, + ) + if adapter_class: + record.set_adapter_class(adapter_class) + return record + + +class TestPluginRegistryReload: + @pytest.fixture + def registry(self): + return PluginRegistry() + + @pytest.mark.asyncio + async def test_reload_success(self, registry): + record = _make_record(plugin_id="feishu", channel_type="feishu", adapter_class=FakeAdapter) + record.set_config({"token": "old"}) + registry.register_builtin(record) + + await registry.activate("feishu") + assert record.status == PluginStatus.ACTIVE + old_adapter = registry.get_adapter("feishu") + assert old_adapter is not None + assert old_adapter._open is True + + new_adapter = await registry.reload("feishu", token="new") + assert new_adapter is not None + assert new_adapter.config["token"] == "new" + assert old_adapter._closed is True + assert record.status == PluginStatus.ACTIVE + assert registry.get_adapter("feishu") is new_adapter + + @pytest.mark.asyncio + async def test_reload_not_found_raises(self, registry): + with pytest.raises(PluginNotFoundException): + await registry.reload("missing") + + @pytest.mark.asyncio + async def test_reload_rollback_on_build_failure(self, registry): + class WorkingAdapter(FakeAdapter): + pass + + class BrokenAdapter(FakeAdapter): + def __init__(self, **kwargs) -> None: + raise RuntimeError("build error") + + record = _make_record(plugin_id="feishu", channel_type="feishu", adapter_class=WorkingAdapter) + record.set_config({"token": "old"}) + registry.register_builtin(record) + + await registry.activate("feishu") + old_adapter = registry.get_adapter("feishu") + assert old_adapter is not None + assert record.status == PluginStatus.ACTIVE + + record.set_adapter_class(BrokenAdapter) + + with pytest.raises(RuntimeError, match="build error"): + await registry.reload("feishu", token="new") + + restored = registry.get_record("feishu") + assert restored.status == PluginStatus.ACTIVE + assert registry.get_adapter("feishu") is old_adapter + + @pytest.mark.asyncio + async def test_reload_rollback_on_open_failure(self, registry): + class WorkingAdapter(FakeAdapter): + pass + + class OpenFailAdapter(FakeAdapter): + async def open(self) -> None: + raise RuntimeError("open error") + + record = _make_record(plugin_id="feishu", channel_type="feishu", adapter_class=WorkingAdapter) + record.set_config({"token": "old"}) + registry.register_builtin(record) + + await registry.activate("feishu") + old_adapter = registry.get_adapter("feishu") + + record.set_adapter_class(OpenFailAdapter) + + with pytest.raises(RuntimeError, match="open error"): + await registry.reload("feishu", token="new") + + restored = registry.get_record("feishu") + assert restored.status == PluginStatus.ACTIVE + assert registry.get_adapter("feishu") is old_adapter + + @pytest.mark.asyncio + async def test_reload_restores_record_state(self, registry): + record = _make_record(plugin_id="feishu", channel_type="feishu", adapter_class=FakeAdapter) + record.set_config({"token": "old"}) + registry.register_builtin(record) + + await registry.activate("feishu") + old_adapter = registry.get_adapter("feishu") + record.error = "some-error" + + class BuildFail(FakeAdapter): + def __init__(self, **kwargs) -> None: + raise RuntimeError("fail") + + record.set_adapter_class(BuildFail) + + with pytest.raises(RuntimeError): + await registry.reload("feishu", token="new") + + restored = registry.get_record("feishu") + assert restored.status == PluginStatus.ACTIVE + assert restored.error == "some-error" + assert registry.get_adapter("feishu") is old_adapter + + +class TestPluginRegistryDeepSnapshot: + def test_deep_snapshot_copies_records(self): + registry = PluginRegistry() + record = _make_record(plugin_id="a", channel_type="a") + registry.register_builtin(record) + + snap = registry._deep_snapshot() + assert snap.plugins["a"] is not record + assert snap.plugins["a"].plugin_id == record.plugin_id + assert snap.plugins["a"].status == record.status + + def test_deep_snapshot_isolation(self): + registry = PluginRegistry() + record = _make_record(plugin_id="a", channel_type="a") + registry.register_builtin(record) + + snap = registry._deep_snapshot() + record.status = PluginStatus.ERROR + assert snap.plugins["a"].status == PluginStatus.REGISTERED + + def test_restore_deep_snapshot(self): + registry = PluginRegistry() + record = _make_record(plugin_id="a", channel_type="a") + registry.register_builtin(record) + + snap = registry._deep_snapshot() + registry.register_builtin(_make_record(plugin_id="b", channel_type="b")) + registry._restore(snap) + assert len(registry.list_records()) == 1 + assert registry.get_record("a") is not None + + +class TestPluginRegistryConcurrency: + @pytest.mark.asyncio + async def test_concurrent_register_no_duplicates(self): + registry = PluginRegistry() + record = _make_record(plugin_id="x", channel_type="x") + success_count = 0 + fail_count = 0 + + async def try_register(): + nonlocal success_count, fail_count + try: + await registry.register(record) + success_count += 1 + except Exception: + fail_count += 1 + + await asyncio.gather(*[try_register() for _ in range(10)]) + assert success_count == 1 + assert fail_count == 9 + + @pytest.mark.asyncio + async def test_concurrent_activate_once(self): + registry = PluginRegistry() + record = _make_record(plugin_id="x", channel_type="x", adapter_class=FakeAdapter) + record.set_config({"token": "t"}) + registry.register_builtin(record) + + results = [] + + async def try_activate(): + try: + adapter = await registry.activate("x") + results.append(("ok", adapter)) + except Exception as exc: + results.append(("err", exc)) + + await asyncio.gather(*[try_activate() for _ in range(5)]) + ok_count = sum(1 for r in results if r[0] == "ok") + assert ok_count == 1 + + @pytest.mark.asyncio + async def test_activate_deactivate_race(self): + registry = PluginRegistry() + record = _make_record(plugin_id="x", channel_type="x", adapter_class=FakeAdapter) + record.set_config({"token": "t"}) + registry.register_builtin(record) + + await registry.activate("x") + + async def deactivate_loop(): + for _ in range(10): + try: + await registry.deactivate("x") + except Exception: + pass + + async def activate_loop(): + for _ in range(10): + try: + await registry.activate("x", token="t") + except Exception: + pass + + await asyncio.gather(deactivate_loop(), activate_loop()) + assert registry.get_record("x") is not None + + +class TestPluginRegistrySnapshotRestore: + def test_snapshot_shallow_vs_deep(self): + registry = PluginRegistry() + record = _make_record(plugin_id="a", channel_type="a") + registry.register_builtin(record) + + shallow = registry._snapshot() + deep = registry._deep_snapshot() + + assert shallow.plugins["a"] is record + assert deep.plugins["a"] is not record + + def test_restore_replaces_adapters(self): + registry = PluginRegistry() + r1 = _make_record(plugin_id="a", channel_type="a", adapter_class=FakeAdapter) + r1.set_config({}) + registry.register_builtin(r1) + + snap1 = registry._deep_snapshot() + new_adapter = FakeAdapter() + registry._adapters["a"] = new_adapter + + registry._restore(snap1) + assert registry.get_adapter("a") is None diff --git a/backend/test/unit/channel/domain/model/session/__init__.py b/backend/test/unit/channel/domain/model/session/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/model/session/__init__.py +++ b/backend/test/unit/channel/domain/model/session/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/shared/__init__.py b/backend/test/unit/channel/domain/model/shared/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/model/shared/__init__.py +++ b/backend/test/unit/channel/domain/model/shared/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/port/__init__.py b/backend/test/unit/channel/domain/port/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/port/__init__.py +++ b/backend/test/unit/channel/domain/port/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/port/test_channel_route_contributor.py b/backend/test/unit/channel/domain/port/test_channel_route_contributor.py index 13caf35f..b7ea5ffa 100644 --- a/backend/test/unit/channel/domain/port/test_channel_route_contributor.py +++ b/backend/test/unit/channel/domain/port/test_channel_route_contributor.py @@ -1,6 +1,6 @@ from __future__ import annotations -from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor +from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor def test_channel_route_contributor_is_protocol() -> None: diff --git a/backend/test/unit/channel/domain/port/test_plugin_ports.py b/backend/test/unit/channel/domain/port/test_plugin_ports.py new file mode 100644 index 00000000..234f5f91 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_plugin_ports.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.plugin_loader_port import PluginLoaderPort +from yuxi.channel.domain.port.plugin_registration_api_port import PluginRegistrationApiPort + + +def test_plugin_loader_port_is_protocol() -> None: + assert hasattr(PluginLoaderPort, "__protocol_attrs__") or hasattr(PluginLoaderPort, "__abstractmethods__") + + +def test_plugin_registration_api_port_is_protocol() -> None: + assert hasattr(PluginRegistrationApiPort, "__protocol_attrs__") or hasattr( + PluginRegistrationApiPort, "__abstractmethods__" + ) + + +class _FakeLoader: + async def load(self, entry_file: str) -> object: + return object() + + def extract_adapter_class(self, module: object) -> type | None: + return None + + +class _FakeRegistrationApi: + @property + def is_closed(self) -> bool: + return False + + def close(self) -> None: + pass + + +def test_plugin_loader_port_implementation() -> None: + loader = _FakeLoader() + assert isinstance(loader, PluginLoaderPort) + + +def test_plugin_registration_api_port_implementation() -> None: + api = _FakeRegistrationApi() + assert isinstance(api, PluginRegistrationApiPort) diff --git a/backend/test/unit/channel/domain/repository/__init__.py b/backend/test/unit/channel/domain/repository/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/repository/__init__.py +++ b/backend/test/unit/channel/domain/repository/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/repository/test_binding_repository.py b/backend/test/unit/channel/domain/repository/test_binding_repository.py index 1ec7bb5c..b11b4090 100644 --- a/backend/test/unit/channel/domain/repository/test_binding_repository.py +++ b/backend/test/unit/channel/domain/repository/test_binding_repository.py @@ -11,13 +11,10 @@ def test_binding_repository_port_is_protocol() -> None: assert hasattr(BindingRepositoryPort, "delete_binding") assert hasattr(BindingRepositoryPort, "get_binding") assert hasattr(BindingRepositoryPort, "list_bindings") - assert hasattr(BindingRepositoryPort, "invalidate_cache") class _FakeBindingRepo: - async def find_binding( - self, *, channel_type: str, account_id: str, group_id: str - ) -> ChannelBinding | None: + async def find_binding(self, *, channel_type: str, account_id: str, group_id: str) -> ChannelBinding | None: return None async def create_binding( @@ -58,11 +55,6 @@ class _FakeBindingRepo: ) -> tuple[list[ChannelBinding], int]: return [], 0 - async def invalidate_cache( - self, *, channel_type: str, account_id: str, group_id: str - ) -> None: - pass - def test_fake_binding_repo_is_instance() -> None: obj = _FakeBindingRepo() diff --git a/backend/test/unit/channel/domain/repository/test_message_log_repository.py b/backend/test/unit/channel/domain/repository/test_message_log_repository.py index bca01eef..b14f4345 100644 --- a/backend/test/unit/channel/domain/repository/test_message_log_repository.py +++ b/backend/test/unit/channel/domain/repository/test_message_log_repository.py @@ -104,6 +104,7 @@ class _FakeMessageLogRepo: trace_id: str, message_id: str, pipeline_result: str, + status: str, abort_reason: str | None = None, ) -> bool: return True @@ -118,7 +119,7 @@ class _FakeMessageLogRepo: error_message: str | None = None, processing_time_ms: int | None = None, agent_config_id: int | None = None, - session_id: int | None = None, + session_id: str | None = None, conversation_id: int | None = None, ) -> bool: return True @@ -129,8 +130,8 @@ class _FakeMessageLogRepo: async def get_by_message_id(self, message_id: str) -> list[MessageLogData]: return [] - async def query_logs(self, query: MessageLogQuery) -> list[MessageLogData]: - return [] + async def query_logs(self, query: MessageLogQuery) -> tuple[list[MessageLogData], int]: + return [], 0 def test_fake_message_log_repo_is_instance() -> None: diff --git a/backend/test/unit/channel/domain/repository/test_outbox_repository.py b/backend/test/unit/channel/domain/repository/test_outbox_repository.py index cda37448..4dd375c8 100644 --- a/backend/test/unit/channel/domain/repository/test_outbox_repository.py +++ b/backend/test/unit/channel/domain/repository/test_outbox_repository.py @@ -6,7 +6,7 @@ from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry def test_outbox_repository_port_is_protocol() -> None: assert hasattr(OutboxRepositoryPort, "enqueue") - assert hasattr(OutboxRepositoryPort, "fetch_pending") + assert hasattr(OutboxRepositoryPort, "fetch_and_lock") assert hasattr(OutboxRepositoryPort, "get_by_id") assert hasattr(OutboxRepositoryPort, "mark_retrying") assert hasattr(OutboxRepositoryPort, "mark_sent") @@ -41,7 +41,7 @@ class _FakeOutboxRepo: extra_metadata=extra_metadata, ) - async def fetch_pending(self, *, limit: int = 20) -> list[OutboxEntry]: + async def fetch_and_lock(self, *, limit: int = 20, worker_id: str = "") -> list[OutboxEntry]: return [] async def get_by_id(self, entry_id: int) -> OutboxEntry | None: diff --git a/backend/test/unit/channel/domain/service/__init__.py b/backend/test/unit/channel/domain/service/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/domain/service/__init__.py +++ b/backend/test/unit/channel/domain/service/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/domain/service/test_plugin_registry_domain_service.py b/backend/test/unit/channel/domain/service/test_plugin_registry_domain_service.py new file mode 100644 index 00000000..d8f83bc4 --- /dev/null +++ b/backend/test/unit/channel/domain/service/test_plugin_registry_domain_service.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.domain.service.plugin_registry_domain_service import PluginRegistryDomainService + + +def _make_record(status=PluginStatus.DISCOVERED) -> PluginRecord: + manifest = PluginManifest(id="t", name="T", version="1.0", channel_type="t", capabilities=ChannelCapabilities()) + source = PluginSource(origin=PluginOrigin.BUNDLED, root_dir=".", entry_file="a.py") + return PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id="t", + name="T", + version="1.0", + channel_type="t", + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + status=status, + ) + + +class TestPluginRegistryDomainService: + def test_can_activate_from_configured(self) -> None: + record = _make_record(status=PluginStatus.CONFIGURED) + assert PluginRegistryDomainService.can_activate(record) is True + + def test_can_activate_from_disabled(self) -> None: + record = _make_record(status=PluginStatus.DISABLED) + assert PluginRegistryDomainService.can_activate(record) is True + + def test_cannot_activate_from_active(self) -> None: + record = _make_record(status=PluginStatus.ACTIVE) + assert PluginRegistryDomainService.can_activate(record) is False + + def test_cannot_activate_from_discovered(self) -> None: + record = _make_record(status=PluginStatus.DISCOVERED) + assert PluginRegistryDomainService.can_activate(record) is False + + def test_can_deactivate_from_active(self) -> None: + record = _make_record(status=PluginStatus.ACTIVE) + assert PluginRegistryDomainService.can_deactivate(record) is True + + def test_cannot_deactivate_from_configured(self) -> None: + record = _make_record(status=PluginStatus.CONFIGURED) + assert PluginRegistryDomainService.can_deactivate(record) is False + + def test_can_reload_from_active(self) -> None: + record = _make_record(status=PluginStatus.ACTIVE) + assert PluginRegistryDomainService.can_reload(record) is True + + def test_cannot_reload_from_disabled(self) -> None: + record = _make_record(status=PluginStatus.DISABLED) + assert PluginRegistryDomainService.can_reload(record) is False + + def test_transition_to_configured_from_registered(self) -> None: + record = _make_record(status=PluginStatus.REGISTERED) + PluginRegistryDomainService.transition_to_configured(record) + assert record.status == PluginStatus.CONFIGURED + + def test_transition_to_configured_from_error(self) -> None: + record = _make_record(status=PluginStatus.ERROR) + PluginRegistryDomainService.transition_to_configured(record) + assert record.status == PluginStatus.CONFIGURED + + def test_transition_to_configured_from_disabled(self) -> None: + record = _make_record(status=PluginStatus.DISABLED) + PluginRegistryDomainService.transition_to_configured(record) + assert record.status == PluginStatus.CONFIGURED + + def test_transition_to_configured_noop_from_active(self) -> None: + record = _make_record(status=PluginStatus.ACTIVE) + PluginRegistryDomainService.transition_to_configured(record) + assert record.status == PluginStatus.ACTIVE + + def test_transition_to_configured_noop_from_discovered(self) -> None: + record = _make_record(status=PluginStatus.DISCOVERED) + PluginRegistryDomainService.transition_to_configured(record) + assert record.status == PluginStatus.DISCOVERED diff --git a/backend/test/unit/channel/infrastructure/__init__.py b/backend/test/unit/channel/infrastructure/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/__init__.py +++ b/backend/test/unit/channel/infrastructure/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/agent/__init__.py b/backend/test/unit/channel/infrastructure/agent/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/agent/__init__.py +++ b/backend/test/unit/channel/infrastructure/agent/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/agent/test_agent_adapter.py b/backend/test/unit/channel/infrastructure/agent/test_agent_adapter.py index 2369724a..7b275e8c 100644 --- a/backend/test/unit/channel/infrastructure/agent/test_agent_adapter.py +++ b/backend/test/unit/channel/infrastructure/agent/test_agent_adapter.py @@ -91,7 +91,9 @@ class TestAgentAdapter: assert user is None @pytest.mark.asyncio - async def test_stream_chat(self, adapter: AgentAdapter, stream_request: StreamChatRequest, fake_session_factory) -> None: + async def test_stream_chat( + self, adapter: AgentAdapter, stream_request: StreamChatRequest, fake_session_factory + ) -> None: session_factory, db = fake_session_factory fake_user = MagicMock() fake_user.id = 1 @@ -100,16 +102,20 @@ class TestAgentAdapter: result_mock.scalar_one_or_none.return_value = fake_user db.execute.return_value = result_mock - mock_svc = _make_chat_service_mock([ - '{"response": "hello", "status": "success"}', - '{"response": " world", "status": "success"}', - ]) + mock_svc = _make_chat_service_mock( + [ + '{"response": "hello", "status": "success"}', + '{"response": " world", "status": "success"}', + ] + ) with patch.dict(sys.modules, {"yuxi.services.chat_service": mock_svc}): result = await adapter.stream_chat(stream_request) assert result == "hello world" @pytest.mark.asyncio - async def test_stream_chat_no_service_user(self, adapter: AgentAdapter, stream_request: StreamChatRequest, fake_session_factory) -> None: + async def test_stream_chat_no_service_user( + self, adapter: AgentAdapter, stream_request: StreamChatRequest, fake_session_factory + ) -> None: session_factory, db = fake_session_factory result_mock = MagicMock() result_mock.scalar_one_or_none.return_value = None @@ -121,7 +127,9 @@ class TestAgentAdapter: assert "Error: no service user found" in result @pytest.mark.asyncio - async def test_stream_chat_error_status(self, adapter: AgentAdapter, stream_request: StreamChatRequest, fake_session_factory) -> None: + async def test_stream_chat_error_status( + self, adapter: AgentAdapter, stream_request: StreamChatRequest, fake_session_factory + ) -> None: session_factory, db = fake_session_factory fake_user = MagicMock() fake_user.id = 1 @@ -130,16 +138,20 @@ class TestAgentAdapter: result_mock.scalar_one_or_none.return_value = fake_user db.execute.return_value = result_mock - mock_svc = _make_chat_service_mock([ - '{"response": "hello", "status": "success"}', - '{"status": "error", "error_message": "something wrong"}', - ]) + mock_svc = _make_chat_service_mock( + [ + '{"response": "hello", "status": "success"}', + '{"status": "error", "error_message": "something wrong"}', + ] + ) with patch.dict(sys.modules, {"yuxi.services.chat_service": mock_svc}): result = await adapter.stream_chat(stream_request) assert "hello" in result @pytest.mark.asyncio - async def test_stream_chat_error_no_accumulated(self, adapter: AgentAdapter, stream_request: StreamChatRequest, fake_session_factory) -> None: + async def test_stream_chat_error_no_accumulated( + self, adapter: AgentAdapter, stream_request: StreamChatRequest, fake_session_factory + ) -> None: session_factory, db = fake_session_factory fake_user = MagicMock() fake_user.id = 1 @@ -148,9 +160,11 @@ class TestAgentAdapter: result_mock.scalar_one_or_none.return_value = fake_user db.execute.return_value = result_mock - mock_svc = _make_chat_service_mock([ - '{"status": "error", "error_message": "something wrong"}', - ]) + mock_svc = _make_chat_service_mock( + [ + '{"status": "error", "error_message": "something wrong"}', + ] + ) with patch.dict(sys.modules, {"yuxi.services.chat_service": mock_svc}): result = await adapter.stream_chat(stream_request) assert "Error: something wrong" in result diff --git a/backend/test/unit/channel/infrastructure/config/__init__.py b/backend/test/unit/channel/infrastructure/config/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/config/__init__.py +++ b/backend/test/unit/channel/infrastructure/config/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/config/test_redis_config_reload.py b/backend/test/unit/channel/infrastructure/config/test_redis_config_reload.py index fcb7a2d0..6e5cb33f 100644 --- a/backend/test/unit/channel/infrastructure/config/test_redis_config_reload.py +++ b/backend/test/unit/channel/infrastructure/config/test_redis_config_reload.py @@ -61,10 +61,14 @@ async def test_watch_calls_callback_on_message(fake_redis: MagicMock, reload: Re callback = AsyncMock() pubsub = MagicMock() pubsub.subscribe = AsyncMock() - pubsub.listen = MagicMock(return_value=async_iter([ - {"type": "subscribe", "channel": "test"}, - {"type": "message", "data": json.dumps({"new": "config"})}, - ])) + pubsub.listen = MagicMock( + return_value=async_iter( + [ + {"type": "subscribe", "channel": "test"}, + {"type": "message", "data": json.dumps({"new": "config"})}, + ] + ) + ) fake_redis.pubsub = MagicMock(return_value=pubsub) task = await _run_watch(reload, callback) @@ -77,9 +81,13 @@ async def test_watch_ignores_non_message(fake_redis: MagicMock, reload: RedisCon callback = AsyncMock() pubsub = MagicMock() pubsub.subscribe = AsyncMock() - pubsub.listen = MagicMock(return_value=async_iter([ - {"type": "subscribe", "channel": "test"}, - ])) + pubsub.listen = MagicMock( + return_value=async_iter( + [ + {"type": "subscribe", "channel": "test"}, + ] + ) + ) fake_redis.pubsub = MagicMock(return_value=pubsub) task = await _run_watch(reload, callback) @@ -91,9 +99,13 @@ async def test_watch_handles_callback_error(fake_redis: MagicMock, reload: Redis callback = AsyncMock(side_effect=Exception("boom")) pubsub = MagicMock() pubsub.subscribe = AsyncMock() - pubsub.listen = MagicMock(return_value=async_iter([ - {"type": "message", "data": json.dumps({"new": "config"})}, - ])) + pubsub.listen = MagicMock( + return_value=async_iter( + [ + {"type": "message", "data": json.dumps({"new": "config"})}, + ] + ) + ) fake_redis.pubsub = MagicMock(return_value=pubsub) task = await _run_watch(reload, callback) @@ -102,6 +114,7 @@ async def test_watch_handles_callback_error(fake_redis: MagicMock, reload: Redis async def _run_watch(reload: RedisConfigReload, callback: AsyncMock) -> None: import asyncio + task = asyncio.create_task(reload.watch(callback)) await asyncio.sleep(0.01) task.cancel() diff --git a/backend/test/unit/channel/infrastructure/content_filter/__init__.py b/backend/test/unit/channel/infrastructure/content_filter/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/content_filter/__init__.py +++ b/backend/test/unit/channel/infrastructure/content_filter/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/content_filter/test_redis_content_filter.py b/backend/test/unit/channel/infrastructure/content_filter/test_redis_content_filter.py index 11f1e9ff..f86c20bb 100644 --- a/backend/test/unit/channel/infrastructure/content_filter/test_redis_content_filter.py +++ b/backend/test/unit/channel/infrastructure/content_filter/test_redis_content_filter.py @@ -54,6 +54,7 @@ async def test_check_refreshes_cache_after_ttl(filter_: RedisContentFilter, fake fake_redis.smembers.return_value = {b"bad"} await filter_.check("content", channel_type="test") import time + filter_._last_refresh = time.monotonic() - 40 await filter_.check("more content", channel_type="test") assert fake_redis.smembers.call_count == 2 diff --git a/backend/test/unit/channel/infrastructure/messaging/__init__.py b/backend/test/unit/channel/infrastructure/messaging/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/messaging/__init__.py +++ b/backend/test/unit/channel/infrastructure/messaging/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_subscriber.py b/backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_subscriber.py index 465a73c0..129fc318 100644 --- a/backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_subscriber.py +++ b/backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_subscriber.py @@ -64,7 +64,9 @@ async def test_stop_cancels_task(subscriber: RedisPubSubSubscriber, fake_redis: @pytest.mark.asyncio -async def test_listen_pushes_event(subscriber: RedisPubSubSubscriber, fake_redis: MagicMock, fake_sse: AsyncMock) -> None: +async def test_listen_pushes_event( + subscriber: RedisPubSubSubscriber, fake_redis: MagicMock, fake_sse: AsyncMock +) -> None: event_data = { "event_type": "message_received", "session_id": "sess-1", @@ -74,9 +76,13 @@ async def test_listen_pushes_event(subscriber: RedisPubSubSubscriber, fake_redis pubsub.psubscribe = AsyncMock() pubsub.unsubscribe = AsyncMock() pubsub.aclose = AsyncMock() - pubsub.listen = MagicMock(return_value=_async_iter([ - {"type": "message", "data": json.dumps(event_data)}, - ])) + pubsub.listen = MagicMock( + return_value=_async_iter( + [ + {"type": "message", "data": json.dumps(event_data)}, + ] + ) + ) fake_redis.pubsub = MagicMock(return_value=pubsub) await subscriber.start() @@ -90,14 +96,20 @@ async def test_listen_pushes_event(subscriber: RedisPubSubSubscriber, fake_redis @pytest.mark.asyncio -async def test_listen_ignores_non_message(subscriber: RedisPubSubSubscriber, fake_redis: MagicMock, fake_sse: AsyncMock) -> None: +async def test_listen_ignores_non_message( + subscriber: RedisPubSubSubscriber, fake_redis: MagicMock, fake_sse: AsyncMock +) -> None: pubsub = MagicMock() pubsub.psubscribe = AsyncMock() pubsub.unsubscribe = AsyncMock() pubsub.aclose = AsyncMock() - pubsub.listen = MagicMock(return_value=_async_iter([ - {"type": "subscribe", "channel": "test"}, - ])) + pubsub.listen = MagicMock( + return_value=_async_iter( + [ + {"type": "subscribe", "channel": "test"}, + ] + ) + ) fake_redis.pubsub = MagicMock(return_value=pubsub) await subscriber.start() @@ -108,14 +120,20 @@ async def test_listen_ignores_non_message(subscriber: RedisPubSubSubscriber, fak @pytest.mark.asyncio -async def test_listen_handles_invalid_json(subscriber: RedisPubSubSubscriber, fake_redis: MagicMock, fake_sse: AsyncMock) -> None: +async def test_listen_handles_invalid_json( + subscriber: RedisPubSubSubscriber, fake_redis: MagicMock, fake_sse: AsyncMock +) -> None: pubsub = MagicMock() pubsub.psubscribe = AsyncMock() pubsub.unsubscribe = AsyncMock() pubsub.aclose = AsyncMock() - pubsub.listen = MagicMock(return_value=_async_iter([ - {"type": "message", "data": "not-json"}, - ])) + pubsub.listen = MagicMock( + return_value=_async_iter( + [ + {"type": "message", "data": "not-json"}, + ] + ) + ) fake_redis.pubsub = MagicMock(return_value=pubsub) await subscriber.start() @@ -126,7 +144,9 @@ async def test_listen_handles_invalid_json(subscriber: RedisPubSubSubscriber, fa @pytest.mark.asyncio -async def test_listen_handles_missing_session_id(subscriber: RedisPubSubSubscriber, fake_redis: MagicMock, fake_sse: AsyncMock) -> None: +async def test_listen_handles_missing_session_id( + subscriber: RedisPubSubSubscriber, fake_redis: MagicMock, fake_sse: AsyncMock +) -> None: event_data = { "event_type": "message_received", "payload": {"message_id": "msg-1"}, @@ -135,9 +155,13 @@ async def test_listen_handles_missing_session_id(subscriber: RedisPubSubSubscrib pubsub.psubscribe = AsyncMock() pubsub.unsubscribe = AsyncMock() pubsub.aclose = AsyncMock() - pubsub.listen = MagicMock(return_value=_async_iter([ - {"type": "message", "data": json.dumps(event_data)}, - ])) + pubsub.listen = MagicMock( + return_value=_async_iter( + [ + {"type": "message", "data": json.dumps(event_data)}, + ] + ) + ) fake_redis.pubsub = MagicMock(return_value=pubsub) await subscriber.start() diff --git a/backend/test/unit/channel/infrastructure/messaging/test_redis_stream_queue.py b/backend/test/unit/channel/infrastructure/messaging/test_redis_stream_queue.py index 0a2b5494..33015f8a 100644 --- a/backend/test/unit/channel/infrastructure/messaging/test_redis_stream_queue.py +++ b/backend/test/unit/channel/infrastructure/messaging/test_redis_stream_queue.py @@ -21,9 +21,7 @@ def queue(fake_redis: AsyncMock) -> RedisStreamQueue: async def test_ensure_group_creates_group(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: fake_redis.xgroup_create.return_value = True await queue.ensure_group() - fake_redis.xgroup_create.assert_awaited_once_with( - STREAM_KEY, CONSUMER_GROUP, id="0", mkstream=True - ) + fake_redis.xgroup_create.assert_awaited_once_with(STREAM_KEY, CONSUMER_GROUP, id="0", mkstream=True) @pytest.mark.asyncio @@ -58,9 +56,12 @@ async def test_enqueue_handles_string_id(queue: RedisStreamQueue, fake_redis: As @pytest.mark.asyncio async def test_dequeue_returns_messages(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: fake_redis.xreadgroup.return_value = [ - (STREAM_KEY, [ - (b"123-0", {b"data": b'{"key": "value"}'}), - ]), + ( + STREAM_KEY, + [ + (b"123-0", {b"data": b'{"key": "value"}'}), + ], + ), ] messages = await queue.dequeue(count=1, block=1000) assert len(messages) == 1 @@ -78,9 +79,12 @@ async def test_dequeue_returns_empty_when_no_messages(queue: RedisStreamQueue, f @pytest.mark.asyncio async def test_dequeue_handles_string_fields(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: fake_redis.xreadgroup.return_value = [ - (STREAM_KEY, [ - (b"123-0", {"data": '{"key": "value"}'}), - ]), + ( + STREAM_KEY, + [ + (b"123-0", {"data": '{"key": "value"}'}), + ], + ), ] messages = await queue.dequeue() assert len(messages) == 1 @@ -90,9 +94,12 @@ async def test_dequeue_handles_string_fields(queue: RedisStreamQueue, fake_redis @pytest.mark.asyncio async def test_dequeue_handles_invalid_json(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: fake_redis.xreadgroup.return_value = [ - (STREAM_KEY, [ - (b"123-0", {b"data": b"not-json"}), - ]), + ( + STREAM_KEY, + [ + (b"123-0", {b"data": b"not-json"}), + ], + ), ] messages = await queue.dequeue() assert len(messages) == 1 diff --git a/backend/test/unit/channel/infrastructure/metrics/__init__.py b/backend/test/unit/channel/infrastructure/metrics/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/metrics/__init__.py +++ b/backend/test/unit/channel/infrastructure/metrics/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/persistence/__init__.py b/backend/test/unit/channel/infrastructure/persistence/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/persistence/__init__.py +++ b/backend/test/unit/channel/infrastructure/persistence/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/persistence/converter/__init__.py b/backend/test/unit/channel/infrastructure/persistence/converter/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/persistence/converter/__init__.py +++ b/backend/test/unit/channel/infrastructure/persistence/converter/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/persistence/repository/__init__.py b/backend/test/unit/channel/infrastructure/persistence/repository/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/persistence/repository/__init__.py +++ b/backend/test/unit/channel/infrastructure/persistence/repository/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_binding_repository.py b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_binding_repository.py index 97b2f200..fbdd4444 100644 --- a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_binding_repository.py +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_binding_repository.py @@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding from yuxi.channel.infrastructure.persistence.repository.pg_binding_repository import ( PgBindingRepository, _BINDING_NULL_MARKER, @@ -35,13 +34,15 @@ def repo(fake_session_factory, fake_cache: AsyncMock) -> PgBindingRepository: class TestPgBindingRepository: @pytest.mark.asyncio async def test_find_binding_from_cache(self, repo: PgBindingRepository, fake_cache: AsyncMock) -> None: - binding_data = json.dumps({ - "id": 1, - "channel_type": "feishu", - "account_id": "acc-1", - "group_id": "grp-1", - "agent_config_id": 5, - }) + binding_data = json.dumps( + { + "id": 1, + "channel_type": "feishu", + "account_id": "acc-1", + "group_id": "grp-1", + "agent_config_id": 5, + } + ) fake_cache.get.return_value = binding_data result = await repo.find_binding(channel_type="feishu", account_id="acc-1", group_id="grp-1") assert result is not None @@ -54,7 +55,9 @@ class TestPgBindingRepository: assert result is None @pytest.mark.asyncio - async def test_find_binding_from_db(self, repo: PgBindingRepository, fake_session_factory, fake_cache: AsyncMock) -> None: + async def test_find_binding_from_db( + self, repo: PgBindingRepository, fake_session_factory, fake_cache: AsyncMock + ) -> None: session_factory, db = fake_session_factory fake_cache.get.return_value = None fake_row = MagicMock() @@ -80,7 +83,9 @@ class TestPgBindingRepository: fake_cache.set.assert_awaited() @pytest.mark.asyncio - async def test_find_binding_not_found(self, repo: PgBindingRepository, fake_session_factory, fake_cache: AsyncMock) -> None: + async def test_find_binding_not_found( + self, repo: PgBindingRepository, fake_session_factory, fake_cache: AsyncMock + ) -> None: session_factory, db = fake_session_factory fake_cache.get.return_value = None result_mock = MagicMock() @@ -118,6 +123,7 @@ class TestPgBindingRepository: ) assert result is not None assert result.channel_type == "feishu" + fake_cache.delete.assert_awaited() @pytest.mark.asyncio async def test_update_binding(self, repo: PgBindingRepository, fake_session_factory, fake_cache: AsyncMock) -> None: @@ -142,6 +148,7 @@ class TestPgBindingRepository: result = await repo.update_binding(1, agent_config_id=10) assert result is not None assert fake_row.agent_config_id == 10 + fake_cache.delete.assert_awaited() @pytest.mark.asyncio async def test_update_binding_not_found(self, repo: PgBindingRepository, fake_session_factory) -> None: @@ -170,6 +177,7 @@ class TestPgBindingRepository: result = await repo.delete_binding(1) assert result is True assert fake_row.is_deleted == 1 + fake_cache.delete.assert_awaited() @pytest.mark.asyncio async def test_delete_binding_not_found(self, repo: PgBindingRepository, fake_session_factory) -> None: @@ -232,8 +240,3 @@ class TestPgBindingRepository: items, total = await repo.list_bindings() assert total == 1 assert len(items) == 1 - - @pytest.mark.asyncio - async def test_invalidate_cache(self, repo: PgBindingRepository, fake_cache: AsyncMock) -> None: - await repo.invalidate_cache(channel_type="feishu", account_id="acc-1", group_id="grp-1") - fake_cache.delete.assert_awaited_once() diff --git a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_log_repository.py b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_log_repository.py index f8f9ad37..2973effe 100644 --- a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_log_repository.py +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_log_repository.py @@ -5,10 +5,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from yuxi.channel.domain.repository.message_log_repository import MessageLogQuery -from yuxi.channel.infrastructure.persistence.repository.pg_message_log_repository import ( - PgMessageLogRepository, - _to_data, -) +from yuxi.channel.infrastructure.persistence.repository.pg_message_log_repository import PgMessageLogRepository @pytest.fixture @@ -110,6 +107,7 @@ class TestPgMessageLogRepository: trace_id="trace-1", message_id="msg-1", pipeline_result="accepted", + status="processing", ) assert result is True @@ -209,11 +207,16 @@ class TestPgMessageLogRepository: fake_row.error_message = None fake_row.processing_time_ms = None - result_mock = MagicMock() - result_mock.scalars.return_value.all.return_value = [fake_row] - db.execute.return_value = result_mock + scalars_result = MagicMock() + scalars_result.scalars.return_value.all.return_value = [fake_row] + + count_result = MagicMock() + count_result.scalar.return_value = 1 + + db.execute = AsyncMock(side_effect=[count_result, scalars_result]) query = MessageLogQuery(channel_type="feishu", status="received") - result = await repo.query_logs(query) - assert len(result) == 1 - assert result[0].channel_type == "feishu" + records, total = await repo.query_logs(query) + assert len(records) == 1 + assert records[0].channel_type == "feishu" + assert total == 1 diff --git a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_repository.py b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_repository.py index 58002e47..5452fcb9 100644 --- a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_repository.py +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_repository.py @@ -1,9 +1,10 @@ from __future__ import annotations -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock import pytest +from yuxi.channel.domain.exception.session_exception import SessionNotFoundError from yuxi.channel.infrastructure.persistence.repository.pg_message_repository import PgMessageRepository @@ -26,58 +27,89 @@ class TestPgMessageRepository: @pytest.mark.asyncio async def test_save_user_message(self, repo: PgMessageRepository, fake_session_factory) -> None: session_factory, db = fake_session_factory + fake_conv = MagicMock() + fake_conv.id = 1 + fake_conv.updated_at = None + fake_msg = MagicMock() - fake_msg.id = 1 + fake_msg.id = 10 - with patch("yuxi.channel.infrastructure.persistence.repository.pg_message_repository.ConversationRepository") as MockConvRepo: - mock_repo = AsyncMock() - mock_repo.add_message_by_thread_id.return_value = fake_msg - MockConvRepo.return_value = mock_repo + scalar_result = MagicMock() + scalar_result.scalar_one_or_none.return_value = fake_conv + db.execute.return_value = scalar_result + db.flush = AsyncMock() - result = await repo.save_user_message( - thread_id="thread-1", - content="hello", - message_id="msg-1", - extra_metadata={"key": "value"}, - ) - assert result.id == 1 - assert result.role == "user" - assert result.thread_id == "thread-1" - assert result.message_id == "msg-1" + async def _fake_refresh(obj): + pass + + db.refresh = AsyncMock(side_effect=_fake_refresh) + + result = await repo.save_user_message( + thread_id="thread-1", + content="hello", + message_id="msg-1", + extra_metadata={"key": "value"}, + ) + assert result.id == 10 + assert result.role == "user" + assert result.thread_id == "thread-1" + assert result.message_id == "msg-1" @pytest.mark.asyncio - async def test_save_user_message_without_message_id(self, repo: PgMessageRepository, fake_session_factory) -> None: + async def test_save_user_message_session_not_found(self, repo: PgMessageRepository, fake_session_factory) -> None: session_factory, db = fake_session_factory - fake_msg = MagicMock() - fake_msg.id = 1 - with patch("yuxi.channel.infrastructure.persistence.repository.pg_message_repository.ConversationRepository") as MockConvRepo: - mock_repo = AsyncMock() - mock_repo.add_message_by_thread_id.return_value = fake_msg - MockConvRepo.return_value = mock_repo + scalar_result = MagicMock() + scalar_result.scalar_one_or_none.return_value = None + db.execute.return_value = scalar_result - result = await repo.save_user_message( - thread_id="thread-1", + with pytest.raises(SessionNotFoundError): + await repo.save_user_message( + thread_id="nonexistent", content="hello", ) - assert result.message_id is None @pytest.mark.asyncio async def test_save_assistant_message(self, repo: PgMessageRepository, fake_session_factory) -> None: session_factory, db = fake_session_factory + fake_conv = MagicMock() + fake_conv.id = 1 + fake_conv.updated_at = None + fake_msg = MagicMock() - fake_msg.id = 2 + fake_msg.id = 20 - with patch("yuxi.channel.infrastructure.persistence.repository.pg_message_repository.ConversationRepository") as MockConvRepo: - mock_repo = AsyncMock() - mock_repo.add_message_by_thread_id.return_value = fake_msg - MockConvRepo.return_value = mock_repo + scalar_result = MagicMock() + scalar_result.scalar_one_or_none.return_value = fake_conv + db.execute.return_value = scalar_result + db.flush = AsyncMock() - result = await repo.save_assistant_message( - thread_id="thread-1", + async def _fake_refresh(obj): + pass + + db.refresh = AsyncMock(side_effect=_fake_refresh) + + result = await repo.save_assistant_message( + thread_id="thread-1", + content="reply", + extra_metadata={"source": "agent"}, + ) + assert result.id == 20 + assert result.role == "assistant" + assert result.content == "reply" + + @pytest.mark.asyncio + async def test_save_assistant_message_session_not_found( + self, repo: PgMessageRepository, fake_session_factory + ) -> None: + session_factory, db = fake_session_factory + + scalar_result = MagicMock() + scalar_result.scalar_one_or_none.return_value = None + db.execute.return_value = scalar_result + + with pytest.raises(SessionNotFoundError): + await repo.save_assistant_message( + thread_id="nonexistent", content="reply", - extra_metadata={"source": "agent"}, ) - assert result.id == 2 - assert result.role == "assistant" - assert result.content == "reply" diff --git a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_outbox_repository.py b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_outbox_repository.py index d72d24ab..1402c95d 100644 --- a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_outbox_repository.py +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_outbox_repository.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from yuxi.channel.domain.model.outbox.outbox_status import OutboxStatus from yuxi.channel.infrastructure.persistence.repository.pg_outbox_repository import PgOutboxRepository @@ -17,19 +18,19 @@ def fake_session_factory(): @pytest.fixture -def fake_redis() -> AsyncMock: +def fake_cache_port() -> AsyncMock: return AsyncMock() @pytest.fixture -def repo(fake_session_factory, fake_redis: AsyncMock) -> PgOutboxRepository: +def repo(fake_session_factory, fake_cache_port: AsyncMock) -> PgOutboxRepository: session_factory, _ = fake_session_factory - return PgOutboxRepository(session_factory=session_factory, redis=fake_redis) + return PgOutboxRepository(session_factory=session_factory, cache_port=fake_cache_port) class TestPgOutboxRepository: @pytest.mark.asyncio - async def test_enqueue(self, repo: PgOutboxRepository, fake_session_factory, fake_redis: AsyncMock) -> None: + async def test_enqueue(self, repo: PgOutboxRepository, fake_session_factory, fake_cache_port: AsyncMock) -> None: session_factory, db = fake_session_factory async def _fake_refresh(obj): @@ -46,13 +47,13 @@ class TestPgOutboxRepository: trace_id="trace-1", ) assert result.id == 1 - assert result.status == "pending" - fake_redis.publish.assert_awaited_once() + assert result.status == OutboxStatus.PENDING + fake_cache_port.publish.assert_awaited_once() @pytest.mark.asyncio - async def test_enqueue_without_redis(self, fake_session_factory) -> None: + async def test_enqueue_without_cache(self, fake_session_factory) -> None: session_factory, db = fake_session_factory - repo = PgOutboxRepository(session_factory=session_factory, redis=None) + repo = PgOutboxRepository(session_factory=session_factory, cache_port=None) async def _fake_refresh(obj): obj.id = 1 @@ -69,7 +70,7 @@ class TestPgOutboxRepository: assert result.id == 1 @pytest.mark.asyncio - async def test_fetch_pending(self, repo: PgOutboxRepository, fake_session_factory) -> None: + async def test_fetch_and_lock(self, repo: PgOutboxRepository, fake_session_factory) -> None: session_factory, db = fake_session_factory fake_row = MagicMock() fake_row.id = 1 @@ -77,7 +78,7 @@ class TestPgOutboxRepository: fake_row.session_id = "sess-1" fake_row.channel_type = "feishu" fake_row.content = "hello" - fake_row.status = "pending" + fake_row.status = OutboxStatus.PENDING fake_row.retry_count = 0 fake_row.max_retries = 3 fake_row.next_retry_at = None @@ -88,10 +89,11 @@ class TestPgOutboxRepository: result_mock = MagicMock() result_mock.scalars.return_value.all.return_value = [fake_row] db.execute.return_value = result_mock + db.flush = AsyncMock() - result = await repo.fetch_pending(limit=10) + result = await repo.fetch_and_lock(limit=10) assert len(result) == 1 - assert result[0].status == "pending" + assert result[0].status == OutboxStatus.PROCESSING @pytest.mark.asyncio async def test_get_by_id(self, repo: PgOutboxRepository, fake_session_factory) -> None: @@ -102,7 +104,7 @@ class TestPgOutboxRepository: fake_row.session_id = "sess-1" fake_row.channel_type = "feishu" fake_row.content = "hello" - fake_row.status = "pending" + fake_row.status = OutboxStatus.PENDING fake_row.retry_count = 0 fake_row.max_retries = 3 fake_row.next_retry_at = None @@ -134,7 +136,8 @@ class TestPgOutboxRepository: fake_row = MagicMock() fake_row.id = 1 fake_row.retry_count = 0 - fake_row.status = "pending" + fake_row.max_retries = 3 + fake_row.status = OutboxStatus.PROCESSING fake_row.next_retry_at = None fake_row.last_error = None @@ -146,58 +149,74 @@ class TestPgOutboxRepository: result = await repo.mark_retrying(1, last_error="timeout") assert result is not None - assert result.status == "retrying" + assert result.status == OutboxStatus.RETRYING assert fake_row.retry_count == 1 + @pytest.mark.asyncio + async def test_mark_retrying_exceeds_max_retries(self, repo: PgOutboxRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.retry_count = 2 + fake_row.max_retries = 3 + fake_row.status = OutboxStatus.PROCESSING + fake_row.next_retry_at = None + fake_row.last_error = None + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + db.flush = AsyncMock() + db.refresh = AsyncMock() + + result = await repo.mark_retrying(1, last_error="timeout") + assert result is not None + assert result.status == OutboxStatus.DEAD + assert fake_row.retry_count == 3 + @pytest.mark.asyncio async def test_mark_sent(self, repo: PgOutboxRepository, fake_session_factory) -> None: session_factory, db = fake_session_factory - fake_row = MagicMock() - fake_row.id = 1 - fake_row.status = "pending" - result_mock = MagicMock() - result_mock.scalar_one_or_none.return_value = fake_row + result_mock.rowcount = 1 db.execute.return_value = result_mock db.flush = AsyncMock() result = await repo.mark_sent(1) assert result is True - assert fake_row.status == "sent" + + @pytest.mark.asyncio + async def test_mark_sent_not_processing(self, repo: PgOutboxRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.rowcount = 0 + db.execute.return_value = result_mock + db.flush = AsyncMock() + + result = await repo.mark_sent(999) + assert result is False @pytest.mark.asyncio async def test_mark_dead(self, repo: PgOutboxRepository, fake_session_factory) -> None: session_factory, db = fake_session_factory - fake_row = MagicMock() - fake_row.id = 1 - fake_row.status = "pending" - result_mock = MagicMock() - result_mock.scalar_one_or_none.return_value = fake_row + result_mock.rowcount = 1 db.execute.return_value = result_mock db.flush = AsyncMock() result = await repo.mark_dead(1, last_error="max retries exceeded") assert result is True - assert fake_row.status == "dead" - assert fake_row.last_error == "max retries exceeded" @pytest.mark.asyncio async def test_mark_failed(self, repo: PgOutboxRepository, fake_session_factory) -> None: session_factory, db = fake_session_factory - fake_row = MagicMock() - fake_row.id = 1 - fake_row.status = "pending" - result_mock = MagicMock() - result_mock.scalar_one_or_none.return_value = fake_row + result_mock.rowcount = 1 db.execute.return_value = result_mock db.flush = AsyncMock() result = await repo.mark_failed(1, last_error="network error") assert result is True - assert fake_row.status == "failed" - assert fake_row.last_error == "network error" @pytest.mark.asyncio async def test_list_outbox(self, repo: PgOutboxRepository, fake_session_factory) -> None: @@ -208,7 +227,7 @@ class TestPgOutboxRepository: fake_row.session_id = "sess-1" fake_row.channel_type = "feishu" fake_row.content = "hello" - fake_row.status = "pending" + fake_row.status = OutboxStatus.PENDING fake_row.retry_count = 0 fake_row.max_retries = 3 fake_row.next_retry_at = None @@ -220,6 +239,6 @@ class TestPgOutboxRepository: result_mock.scalars.return_value.all.return_value = [fake_row] db.execute.return_value = result_mock - result = await repo.list_outbox(channel_type="feishu", status="pending") + result = await repo.list_outbox(channel_type="feishu", status=OutboxStatus.PENDING) assert len(result) == 1 assert result[0].channel_type == "feishu" diff --git a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_session_repository.py b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_session_repository.py index 5ff879ca..6554e342 100644 --- a/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_session_repository.py +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_session_repository.py @@ -58,7 +58,9 @@ class TestPgSessionRepository: assert result.thread_id == "thread-1" @pytest.mark.asyncio - async def test_get_or_create_creates_new(self, repo: PgSessionRepository, fake_session_factory, fake_cache: AsyncMock) -> None: + async def test_get_or_create_creates_new( + self, repo: PgSessionRepository, fake_session_factory, fake_cache: AsyncMock + ) -> None: session_factory, db = fake_session_factory fake_cache.set.return_value = True @@ -77,7 +79,9 @@ class TestPgSessionRepository: assert result.channel_type == "feishu" @pytest.mark.asyncio - async def test_get_or_create_handles_integrity_error(self, repo: PgSessionRepository, fake_session_factory, fake_cache: AsyncMock) -> None: + async def test_get_or_create_handles_integrity_error( + self, repo: PgSessionRepository, fake_session_factory, fake_cache: AsyncMock + ) -> None: session_factory, db = fake_session_factory fake_cache.set.return_value = True diff --git a/backend/test/unit/channel/infrastructure/plugin/__init__.py b/backend/test/unit/channel/infrastructure/plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/infrastructure/plugin/test_api_guard.py b/backend/test/unit/channel/infrastructure/plugin/test_api_guard.py new file mode 100644 index 00000000..468f499c --- /dev/null +++ b/backend/test/unit/channel/infrastructure/plugin/test_api_guard.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.infrastructure.plugin.api_guard import ApiGuard, PluginRegistrationApi + + +class TestPluginRegistrationApi: + def test_init(self): + api = PluginRegistrationApi("plugin1", {"key": "value"}) + assert api.config == {"key": "value"} + assert api.is_closed is False + + def test_close(self): + api = PluginRegistrationApi("plugin1") + api.close() + assert api.is_closed is True + + def test_config_after_close_raises(self): + api = PluginRegistrationApi("plugin1") + api.close() + with pytest.raises(RuntimeError, match="registration API closed"): + _ = api.config + + +class TestApiGuard: + def test_create(self): + guard = ApiGuard() + api = guard.create("plugin1", {"foo": "bar"}) + assert isinstance(api, PluginRegistrationApi) + assert api.config == {"foo": "bar"} + + def test_create_without_config(self): + guard = ApiGuard() + api = guard.create("plugin1") + assert api.config == {} diff --git a/backend/test/unit/channel/infrastructure/plugin/test_api_guard_and_factory.py b/backend/test/unit/channel/infrastructure/plugin/test_api_guard_and_factory.py new file mode 100644 index 00000000..099eae30 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/plugin/test_api_guard_and_factory.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.model.plugin_registry.plugin_registry_factory import PluginRegistryFactory +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.infrastructure.plugin.api_guard import ApiGuard, PluginRegistrationApi + + +class TestApiGuard: + def test_create_api(self): + guard = ApiGuard() + api = guard.create("test-plugin") + assert not api.is_closed + + def test_close_api(self): + api = PluginRegistrationApi("test") + assert not api.is_closed + api.close() + assert api.is_closed + + def test_closed_api_config_raises(self): + api = PluginRegistrationApi("test", {"key": "val"}) + api.close() + with pytest.raises(RuntimeError): + _ = api.config + + +class TestPluginRegistryFactory: + def test_create_builtin(self): + class FakeAdapter: + @classmethod + def get_default_config(cls): + return {} + + @property + def capabilities(self): + return ChannelCapabilities(media=True) + + record = PluginRegistryFactory.create_builtin( + plugin_id="test", + name="Test", + channel_type="test", + adapter_class=FakeAdapter, + ) + assert record.plugin_id == "test" + assert record.channel_type == "test" + assert record.source.origin == PluginOrigin.BUNDLED + assert record.adapter_class is FakeAdapter + assert record.status == PluginStatus.DISCOVERED + + def test_create_from_manifest(self): + from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest + + manifest = PluginManifest( + id="custom", + name="Custom", + version="2.0.0", + channel_type="custom", + capabilities=ChannelCapabilities(dm=True), + ) + source = PluginSource( + origin=PluginOrigin.WORKSPACE, + root_dir="plugins/custom", + entry_file="plugins/custom/adapter.py", + ) + record = PluginRegistryFactory.create_from_manifest(manifest, source) + assert record.plugin_id == "custom" + assert record.version == "2.0.0" + assert record.source.origin == PluginOrigin.WORKSPACE diff --git a/backend/test/unit/channel/infrastructure/plugin/test_discovery.py b/backend/test/unit/channel/infrastructure/plugin/test_discovery.py new file mode 100644 index 00000000..1207190a --- /dev/null +++ b/backend/test/unit/channel/infrastructure/plugin/test_discovery.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin +from yuxi.channel.infrastructure.plugin.discovery import PluginDiscovery +from yuxi.channel.infrastructure.plugin.manifest_parser import ManifestParser + + +class TestPluginDiscovery: + @pytest.fixture + def discovery(self): + return PluginDiscovery() + + @pytest.mark.asyncio + async def test_discover_workspace_empty_dir(self, discovery): + with tempfile.TemporaryDirectory() as tmpdir: + candidates = await discovery.discover_workspace(tmpdir) + assert candidates == [] + + @pytest.mark.asyncio + async def test_discover_workspace_skips_no_manifest(self, discovery): + with tempfile.TemporaryDirectory() as tmpdir: + Path(tmpdir, "plugin1").mkdir() + candidates = await discovery.discover_workspace(tmpdir) + assert candidates == [] + + @pytest.mark.asyncio + async def test_discover_workspace_skips_hidden_dirs(self, discovery): + with tempfile.TemporaryDirectory() as tmpdir: + Path(tmpdir, ".hidden").mkdir() + Path(tmpdir, "_private").mkdir() + candidates = await discovery.discover_workspace(tmpdir) + assert candidates == [] + + @pytest.mark.asyncio + async def test_discover_workspace_finds_manifest(self, discovery): + with tempfile.TemporaryDirectory() as tmpdir: + plugin_dir = Path(tmpdir, "my_plugin") + plugin_dir.mkdir() + manifest_path = plugin_dir / "yuxi.plugin.yaml" + manifest_path.write_text( + "id: my_plugin\nname: My Plugin\nversion: 1.0.0\nchannel_type: test\nentry: adapter.py\n" + ) + + candidates = await discovery.discover_workspace(tmpdir) + assert len(candidates) == 1 + assert candidates[0].plugin_id == "my_plugin" + assert candidates[0].source.origin == PluginOrigin.WORKSPACE + assert candidates[0].source.entry_file.endswith("adapter.py") + + @pytest.mark.asyncio + async def test_discover_all_combines_sources(self, discovery): + with tempfile.TemporaryDirectory() as ws_dir, tempfile.TemporaryDirectory() as inst_dir: + for d, name in [(ws_dir, "ws_plugin"), (inst_dir, "inst_plugin")]: + plugin_dir = Path(d, name) + plugin_dir.mkdir() + manifest_path = plugin_dir / "yuxi.plugin.yaml" + manifest_path.write_text(f"id: {name}\nname: {name.capitalize()}\nversion: 1.0.0\nchannel_type: test\n") + + discovery_ws = PluginDiscovery() + candidates_ws = await discovery_ws.discover_workspace(ws_dir) + candidates_inst = await discovery_ws.discover_installed(inst_dir) + assert len(candidates_ws) == 1 + assert len(candidates_inst) == 1 + + @pytest.mark.asyncio + async def test_find_existing_plugin(self, discovery): + with tempfile.TemporaryDirectory() as tmpdir: + plugin_dir = Path(tmpdir, "target") + plugin_dir.mkdir() + manifest_path = plugin_dir / "yuxi.plugin.yaml" + manifest_path.write_text("id: target\nname: Target\nversion: 1.0.0\nchannel_type: test\n") + + result = await discovery.find("target") + assert result is None + + result = await PluginDiscovery().discover_workspace(tmpdir) + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_find_nonexistent_plugin(self, discovery): + result = await discovery.find("nonexistent") + assert result is None diff --git a/backend/test/unit/channel/infrastructure/plugin/test_loader.py b/backend/test/unit/channel/infrastructure/plugin/test_loader.py new file mode 100644 index 00000000..6d0b95b7 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/plugin/test_loader.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import sys +import tempfile +from pathlib import Path + +import pytest + +from yuxi.channel.infrastructure.plugin.loader import PluginLoader + + +class FakeAdapterClass: + def __init__(self, **kwargs) -> None: + pass + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + @property + def capabilities(self): + return None + + @classmethod + def get_default_config(cls) -> dict: + return {} + + +class TestPluginLoader: + @pytest.fixture + def loader(self): + return PluginLoader() + + @pytest.mark.asyncio + async def test_load_module_by_path(self, loader): + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f: + f.write("class MyAdapter:\n") + f.write(" def __init__(self, **kwargs): pass\n") + f.write(" async def open(self): pass\n") + f.write(" async def close(self): pass\n") + f.write(" @property\n") + f.write(" def capabilities(self): return None\n") + f.write(" @classmethod\n") + f.write(" def get_default_config(cls): return {}\n") + path = f.name + + try: + module = await loader.load(path) + assert module is not None + assert hasattr(module, "MyAdapter") + finally: + Path(path).unlink() + + @pytest.mark.asyncio + async def test_load_existing_module(self, loader): + module = await loader.load("sys") + assert module is sys + + def test_extract_adapter_class_found(self, loader): + module = type("Module", (), {"MyAdapter": FakeAdapterClass})() + result = loader.extract_adapter_class(module) + assert result is FakeAdapterClass + + def test_extract_adapter_class_none(self, loader): + module = type("Module", (), {"foo": "bar"})() + result = loader.extract_adapter_class(module) + assert result is None + + def test_extract_adapter_class_skips_private(self, loader): + class _PrivateAdapter(FakeAdapterClass): + pass + + module = type("Module", (), {"_PrivateAdapter": _PrivateAdapter})() + result = loader.extract_adapter_class(module) + assert result is None + + def test_extract_adapter_class_requires_methods(self, loader): + class Incomplete: + pass + + module = type("Module", (), {"Incomplete": Incomplete})() + result = loader.extract_adapter_class(module) + assert result is None diff --git a/backend/test/unit/channel/infrastructure/plugin/test_manifest_parser.py b/backend/test/unit/channel/infrastructure/plugin/test_manifest_parser.py new file mode 100644 index 00000000..00eab71e --- /dev/null +++ b/backend/test/unit/channel/infrastructure/plugin/test_manifest_parser.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.infrastructure.plugin.manifest_parser import ManifestParser + + +class TestManifestParser: + @pytest.fixture + def parser(self): + return ManifestParser() + + def test_parse_valid_manifest(self, parser): + data = { + "id": "wechat-work", + "name": "WeChat Work", + "version": "1.0.0", + "channel_type": "wechat_work", + "capabilities": { + "media": True, + "group": True, + "dm": True, + "streaming": False, + "typing": True, + "reaction": False, + "thread": False, + "max_text_length": 2048, + }, + "config_schema": { + "type": "object", + "required": ["corp_id", "agent_id", "secret"], + }, + "entry": "./adapter.py", + "dependencies": [], + } + result = parser.parse(data) + assert result.manifest.id == "wechat-work" + assert result.manifest.name == "WeChat Work" + assert result.manifest.capabilities.media is True + assert result.manifest.capabilities.max_text_length == 2048 + assert result.manifest.config_schema is not None + assert result.manifest.entry == "./adapter.py" + + def test_parse_minimal_manifest(self, parser): + data = { + "id": "minimal", + "name": "Minimal", + "channel_type": "minimal", + } + result = parser.parse(data) + assert result.manifest.id == "minimal" + assert result.manifest.version == "0.0.0" + assert result.manifest.capabilities.dm is True + assert result.manifest.capabilities.media is False + + def test_parse_missing_id_raises(self, parser): + from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException + + with pytest.raises(InvalidManifestException): + parser.parse({"name": "No ID", "channel_type": "test"}) + + def test_parse_missing_channel_type_raises(self, parser): + from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException + + with pytest.raises(InvalidManifestException): + parser.parse({"id": "test", "name": "No Type"}) + + def test_parse_file_not_found(self, parser): + from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException + + with pytest.raises(InvalidManifestException): + asyncio.run(parser.parse_file("/nonexistent/path/yuxi.plugin.yaml")) + + +import asyncio diff --git a/backend/test/unit/channel/infrastructure/plugin/test_registry_impl.py b/backend/test/unit/channel/infrastructure/plugin/test_registry_impl.py new file mode 100644 index 00000000..92d3d278 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/plugin/test_registry_impl.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yuxi.channel.domain.exception.plugin_exception import PluginRegistrationException +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord +from yuxi.channel.domain.model.plugin_registry.plugin_registration_mode import PluginRegistrationMode +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.infrastructure.plugin.discovery import PluginCandidate +from yuxi.channel.infrastructure.plugin.loader import PluginLoader +from yuxi.channel.infrastructure.plugin.manifest_parser import ManifestParser +from yuxi.channel.infrastructure.plugin.registry_impl import ( + PluginRegistryImpl, + PluginRegistryImplConfig, + extract_capabilities, + get_registry, + set_registry, +) + + +class FakeAdapter: + def __init__(self, **kwargs) -> None: + self.config = kwargs + self._open = False + + async def open(self) -> None: + self._open = True + + async def close(self) -> None: + pass + + @property + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities() + + @property + def channel_type(self) -> str: + return "fake" + + @classmethod + def get_default_config(cls) -> dict: + return {"default_key": "default_value"} + + +class FakeAdapterNoCaps: + def __init__(self, **kwargs) -> None: + pass + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + @classmethod + def get_default_config(cls) -> dict: + return {} + + +@pytest.fixture(autouse=True) +def reset_registry(): + set_registry(None) + yield + set_registry(None) + + +class TestPluginRegistryImplLifecycle: + def test_init_sets_global_registry(self): + impl = PluginRegistryImpl() + assert get_registry() is impl + + def test_config_defaults(self): + impl = PluginRegistryImpl() + assert impl._config.workspace_dir == "plugins" + assert impl._config.installed_dir == "installed/plugins" + + def test_registry_property(self): + impl = PluginRegistryImpl() + assert impl.registry is impl._registry + + +class TestRegisterBuiltinAdapter: + def test_register_builtin_adapter(self): + impl = PluginRegistryImpl() + impl.register_builtin_adapter("web", FakeAdapter) + record = impl.registry.get_record("web") + assert record is not None + assert record.plugin_id == "web" + assert record.adapter_class is FakeAdapter + + def test_register_builtin_adapter_idempotent(self): + impl = PluginRegistryImpl() + impl.register_builtin_adapter("web", FakeAdapter) + impl.register_builtin_adapter("web", FakeAdapter) + assert len(impl.registry.list_records()) == 1 + + def test_get_registered_adapter_classes(self): + impl = PluginRegistryImpl() + impl.register_builtin_adapter("web", FakeAdapter) + classes = impl.get_registered_adapter_classes() + assert classes["web"] is FakeAdapter + + +class TestResolveConfig: + def test_resolve_config_default(self): + impl = PluginRegistryImpl() + manifest = PluginManifest( + id="test", + name="Test", + version="1.0.0", + channel_type="test", + capabilities=ChannelCapabilities(), + ) + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="channels/test", + entry_file="channels/test/adapter.py", + ) + record = PluginRecord( + record_id=MagicMock(), + plugin_id="test", + name="Test", + version="1.0.0", + channel_type="test", + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + ) + record.set_adapter_class(FakeAdapter) + config = impl._resolve_config(record) + assert config["default_key"] == "default_value" + + def test_resolve_config_with_channel_config(self): + channel_config = MagicMock() + channel_config.get_channel_config.return_value = {"override": "yes"} + + impl = PluginRegistryImpl(channel_config=channel_config) + manifest = PluginManifest( + id="test", + name="Test", + version="1.0.0", + channel_type="test", + capabilities=ChannelCapabilities(), + ) + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="channels/test", + entry_file="channels/test/adapter.py", + ) + record = PluginRecord( + record_id=MagicMock(), + plugin_id="test", + name="Test", + version="1.0.0", + channel_type="test", + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + ) + record.set_adapter_class(FakeAdapter) + config = impl._resolve_config(record) + assert config["override"] == "yes" + assert config["default_key"] == "default_value" + + def test_resolve_config_web_sse(self): + sse = MagicMock() + impl = PluginRegistryImpl(sse_endpoint=sse) + manifest = PluginManifest( + id="web", + name="Web", + version="1.0.0", + channel_type="web", + capabilities=ChannelCapabilities(), + ) + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="channels/web", + entry_file="channels/web/adapter.py", + ) + record = PluginRecord( + record_id=MagicMock(), + plugin_id="web", + name="Web", + version="1.0.0", + channel_type="web", + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + ) + record.set_adapter_class(FakeAdapter) + config = impl._resolve_config(record) + assert config["sse_push"] is sse + + def test_resolve_config_hooks_mappings(self): + channel_config = MagicMock() + channel_config.get_channel_config.return_value = {} + channel_config.hook_mappings = {"hook1": "url1"} + + impl = PluginRegistryImpl(channel_config=channel_config) + manifest = PluginManifest( + id="hooks", + name="Hooks", + version="1.0.0", + channel_type="hooks", + capabilities=ChannelCapabilities(), + ) + source = PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="channels/hooks", + entry_file="channels/hooks/adapter.py", + ) + record = PluginRecord( + record_id=MagicMock(), + plugin_id="hooks", + name="Hooks", + version="1.0.0", + channel_type="hooks", + source=source, + manifest=manifest, + capabilities=ChannelCapabilities(), + ) + record.set_adapter_class(FakeAdapter) + config = impl._resolve_config(record) + assert config["mappings"] == {"hook1": "url1"} + + def test_apply_env_overrides_feishu(self): + with patch.dict(os.environ, {"FEISHU_APP_ID": "app123", "FEISHU_APP_SECRET": "sec456"}): + impl = PluginRegistryImpl() + env_mapping = {"FEISHU_APP_ID": "app_id", "FEISHU_APP_SECRET": "app_secret"} + result = impl._apply_env_overrides_generic(env_mapping) + assert result["app_id"] == "app123" + assert result["app_secret"] == "sec456" + + def test_apply_env_overrides_dingtalk(self): + with patch.dict(os.environ, {"DINGTALK_CLIENT_ID": "cid", "DINGTALK_CLIENT_SECRET": "csec"}): + impl = PluginRegistryImpl() + env_mapping = {"DINGTALK_CLIENT_ID": "client_id", "DINGTALK_CLIENT_SECRET": "client_secret"} + result = impl._apply_env_overrides_generic(env_mapping) + assert result["client_id"] == "cid" + assert result["client_secret"] == "csec" + + def test_apply_env_overrides_empty(self): + impl = PluginRegistryImpl() + result = impl._apply_env_overrides_generic({}) + assert result == {} + + +class TestRegisterPlugin: + @pytest.mark.asyncio + async def test_register_plugin_discovery_mode(self): + impl = PluginRegistryImpl() + candidate = PluginCandidate( + plugin_id="test", + source=PluginSource( + origin=PluginOrigin.WORKSPACE, + root_dir="plugins/test", + entry_file="", + ), + manifest_data={ + "id": "test", + "name": "Test", + "version": "1.0.0", + "channel_type": "test", + }, + ) + record = await impl.register_plugin(candidate, mode=PluginRegistrationMode.DISCOVERY) + assert record.plugin_id == "test" + assert record.status.value == "registered" + + @pytest.mark.asyncio + async def test_register_plugin_no_manifest_raises(self): + impl = PluginRegistryImpl() + candidate = PluginCandidate( + plugin_id="test", + source=PluginSource( + origin=PluginOrigin.WORKSPACE, + root_dir="plugins/test", + entry_file="", + ), + manifest_data=None, + ) + with pytest.raises(PluginRegistrationException): + await impl.register_plugin(candidate) + + @pytest.mark.asyncio + async def test_register_plugin_full_mode_with_activation(self): + impl = PluginRegistryImpl() + candidate = PluginCandidate( + plugin_id="test", + source=PluginSource( + origin=PluginOrigin.WORKSPACE, + root_dir="plugins/test", + entry_file="", + enabled=True, + ), + manifest_data={ + "id": "test", + "name": "Test", + "version": "1.0.0", + "channel_type": "test", + }, + ) + record = await impl.register_plugin(candidate, mode=PluginRegistrationMode.DISCOVERY) + assert record is not None + + +class TestDiscoverAndRegisterAll: + @pytest.mark.asyncio + async def test_discover_and_register_all_empty(self): + impl = PluginRegistryImpl() + with patch.object(impl._discovery, "discover_all", return_value=[]): + records = await impl.discover_and_register_all() + assert records == [] + + @pytest.mark.asyncio + async def test_discover_and_register_all_skips_failed(self): + impl = PluginRegistryImpl() + candidate = PluginCandidate( + plugin_id="bad", + source=PluginSource( + origin=PluginOrigin.WORKSPACE, + root_dir="plugins/bad", + entry_file="", + ), + manifest_data=None, + ) + with patch.object(impl._discovery, "discover_all", return_value=[candidate]): + records = await impl.discover_and_register_all() + assert records == [] + + +class TestExtractCapabilities: + def test_extract_from_instance(self): + caps = extract_capabilities(FakeAdapter) + assert isinstance(caps, ChannelCapabilities) + + def test_extract_no_capabilities_attr(self): + caps = extract_capabilities(FakeAdapterNoCaps) + assert isinstance(caps, ChannelCapabilities) + + def test_extract_class_attribute(self): + class AdapterWithClassCap: + capabilities = ChannelCapabilities(media=True) + + def __init__(self, **kwargs) -> None: + pass + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + @classmethod + def get_default_config(cls) -> dict: + return {} + + caps = extract_capabilities(AdapterWithClassCap) + assert caps.media is True diff --git a/backend/test/unit/channel/infrastructure/plugin/test_snapshot.py b/backend/test/unit/channel/infrastructure/plugin/test_snapshot.py new file mode 100644 index 00000000..8cae6f6c --- /dev/null +++ b/backend/test/unit/channel/infrastructure/plugin/test_snapshot.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry, RegistrySnapshot +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.infrastructure.plugin.snapshot import restore_snapshot, take_snapshot + + +class FakeAdapter: + def __init__(self, **kwargs) -> None: + pass + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + +class TestSnapshot: + def test_take_snapshot(self): + registry = PluginRegistry() + record = PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id="test", + name="Test", + version="1.0.0", + channel_type="test", + source=PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="channels/test", + entry_file="channels/test/adapter.py", + ), + manifest=PluginManifest( + id="test", + name="Test", + version="1.0.0", + channel_type="test", + capabilities=ChannelCapabilities(), + ), + capabilities=ChannelCapabilities(), + ) + registry.register_builtin(record) + + result = take_snapshot(registry) + assert result.plugin_count == 1 + assert result.adapter_count == 0 + assert isinstance(result.snapshot, RegistrySnapshot) + + def test_restore_snapshot(self): + registry = PluginRegistry() + record = PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id="test", + name="Test", + version="1.0.0", + channel_type="test", + source=PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="channels/test", + entry_file="channels/test/adapter.py", + ), + manifest=PluginManifest( + id="test", + name="Test", + version="1.0.0", + channel_type="test", + capabilities=ChannelCapabilities(), + ), + capabilities=ChannelCapabilities(), + ) + registry.register_builtin(record) + + snap = take_snapshot(registry) + registry.register_builtin( + PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id="extra", + name="Extra", + version="1.0.0", + channel_type="extra", + source=PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="channels/extra", + entry_file="channels/extra/adapter.py", + ), + manifest=PluginManifest( + id="extra", + name="Extra", + version="1.0.0", + channel_type="extra", + capabilities=ChannelCapabilities(), + ), + capabilities=ChannelCapabilities(), + ) + ) + assert len(registry.list_records()) == 2 + + restore_snapshot(registry, snap.snapshot) + assert len(registry.list_records()) == 1 + assert registry.get_record("test") is not None + + def test_take_snapshot_invalid_type_raises(self): + with pytest.raises(TypeError, match="registry must be PluginRegistry"): + take_snapshot(object()) + + def test_restore_snapshot_invalid_type_raises(self): + with pytest.raises(TypeError, match="registry must be PluginRegistry"): + restore_snapshot(object(), RegistrySnapshot(plugins={}, adapters={})) diff --git a/backend/test/unit/channel/infrastructure/security/__init__.py b/backend/test/unit/channel/infrastructure/security/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/infrastructure/security/__init__.py +++ b/backend/test/unit/channel/infrastructure/security/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/interfaces/__init__.py b/backend/test/unit/channel/interfaces/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/interfaces/__init__.py +++ b/backend/test/unit/channel/interfaces/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/interfaces/rest/__init__.py b/backend/test/unit/channel/interfaces/rest/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/interfaces/rest/__init__.py +++ b/backend/test/unit/channel/interfaces/rest/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/interfaces/rest/auth/__init__.py b/backend/test/unit/channel/interfaces/rest/auth/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/interfaces/rest/auth/__init__.py +++ b/backend/test/unit/channel/interfaces/rest/auth/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/interfaces/rest/auth/test_depends.py b/backend/test/unit/channel/interfaces/rest/auth/test_depends.py index ae3cd0e8..adf365e2 100644 --- a/backend/test/unit/channel/interfaces/rest/auth/test_depends.py +++ b/backend/test/unit/channel/interfaces/rest/auth/test_depends.py @@ -15,7 +15,9 @@ class _FakeRateLimiter: async def is_locked(self, key: str) -> tuple[bool, int]: return self.locked, self.remaining - async def check_and_incr(self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0) -> bool: + async def check_and_incr( + self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0 + ) -> bool: return self.attempts_allowed async def reset(self, key: str) -> None: @@ -58,6 +60,7 @@ class _FakeAuthService: if auth_header.startswith("Basic "): try: import base64 + decoded = base64.b64decode(auth_header[6:]).decode() if ":" in decoded and self._password: _, pwd = decoded.split(":", 1) @@ -143,6 +146,7 @@ async def test_channel_auth_depends_with_rate_limited_raises_429(): async def test_channel_auth_depends_with_basic_auth(): container = _FakeChannelContainer(auth_service=_FakeAuthService(password="secret-password")) import base64 + creds = base64.b64encode(b"user:secret-password").decode() result = await channel_auth_depends( authorization=f"Basic {creds}", diff --git a/backend/test/unit/channel/interfaces/rest/middleware/__init__.py b/backend/test/unit/channel/interfaces/rest/middleware/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/interfaces/rest/middleware/__init__.py +++ b/backend/test/unit/channel/interfaces/rest/middleware/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/interfaces/rest/middleware/test_exception_handler.py b/backend/test/unit/channel/interfaces/rest/middleware/test_exception_handler.py index fc7f791b..326a37af 100644 --- a/backend/test/unit/channel/interfaces/rest/middleware/test_exception_handler.py +++ b/backend/test/unit/channel/interfaces/rest/middleware/test_exception_handler.py @@ -51,6 +51,7 @@ async def test_channel_exception_handler_generates_trace_id_when_missing(): assert "bad request" in body # trace_id should be generated (uuid4 format) import json + data = json.loads(body) assert data["trace_id"] is not None assert len(data["trace_id"]) > 0 diff --git a/backend/test/unit/channel/interfaces/rest/middleware/test_trace_id.py b/backend/test/unit/channel/interfaces/rest/middleware/test_trace_id.py index 051e565e..e2b419e8 100644 --- a/backend/test/unit/channel/interfaces/rest/middleware/test_trace_id.py +++ b/backend/test/unit/channel/interfaces/rest/middleware/test_trace_id.py @@ -19,12 +19,14 @@ async def test_trace_id_middleware_extracts_from_header(): response = Response(content="ok") return response - request = Request(scope={ - "type": "http", - "method": "GET", - "path": "/", - "headers": [(b"x-trace-id", b"existing-trace-id")], - }) + request = Request( + scope={ + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"x-trace-id", b"existing-trace-id")], + } + ) response = await middleware.dispatch(request, call_next) assert response.headers["X-Trace-Id"] == "existing-trace-id" @@ -39,12 +41,14 @@ async def test_trace_id_middleware_extracts_from_request_id_header(): response = Response(content="ok") return response - request = Request(scope={ - "type": "http", - "method": "GET", - "path": "/", - "headers": [(b"x-request-id", b"request-id-123")], - }) + request = Request( + scope={ + "type": "http", + "method": "GET", + "path": "/", + "headers": [(b"x-request-id", b"request-id-123")], + } + ) response = await middleware.dispatch(request, call_next) assert response.headers["X-Trace-Id"] == "request-id-123" @@ -59,12 +63,14 @@ async def test_trace_id_middleware_generates_uuid_when_no_header(): response = Response(content="ok") return response - request = Request(scope={ - "type": "http", - "method": "GET", - "path": "/", - "headers": [], - }) + request = Request( + scope={ + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + } + ) response = await middleware.dispatch(request, call_next) assert "X-Trace-Id" in response.headers diff --git a/backend/test/unit/channel/interfaces/rest/router/__init__.py b/backend/test/unit/channel/interfaces/rest/router/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/interfaces/rest/router/__init__.py +++ b/backend/test/unit/channel/interfaces/rest/router/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/interfaces/rest/router/test_bindings.py b/backend/test/unit/channel/interfaces/rest/router/test_bindings.py index ea1b4f46..2faa129a 100644 --- a/backend/test/unit/channel/interfaces/rest/router/test_bindings.py +++ b/backend/test/unit/channel/interfaces/rest/router/test_bindings.py @@ -13,7 +13,9 @@ class _FakeBindingService: self._bindings: dict[int, ChannelBinding] = {} self._next_id = 1 - async def create(self, *, channel_type: str, account_id: str, group_id: str, agent_config_id: int) -> ChannelBinding: + async def create( + self, *, channel_type: str, account_id: str, group_id: str, agent_config_id: int + ) -> ChannelBinding: binding = ChannelBinding( id=self._next_id, channel_type=channel_type, @@ -26,11 +28,13 @@ class _FakeBindingService: self._next_id += 1 return binding - async def list(self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50) -> tuple[list[ChannelBinding], int]: + async def list( + self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50 + ) -> tuple[list[ChannelBinding], int]: items = list(self._bindings.values()) if channel_type: items = [b for b in items if b.channel_type == channel_type] - return items[offset:offset + limit], len(items) + return items[offset : offset + limit], len(items) async def delete(self, binding_id: int) -> bool: if binding_id in self._bindings: @@ -63,7 +67,9 @@ def _build_app() -> FastAPI: app.dependency_overrides = { __import__("yuxi.channel.container", fromlist=["get_channel"]).get_channel: fake_get_channel, - __import__("yuxi.channel.interfaces.rest.auth.depends", fromlist=["channel_auth_depends"]).channel_auth_depends: fake_auth, + __import__( + "yuxi.channel.interfaces.rest.auth.depends", fromlist=["channel_auth_depends"] + ).channel_auth_depends: fake_auth, } app.state.channel = container return app @@ -92,96 +98,217 @@ def client(): return TestClient(app) -def test_create_binding(client): - resp = client.post("/channel/bindings", json={ - "channel_type": "web", - "account_id": "test-account", - "group_id": "test-group", - "agent_config_id": 1, - }) - assert resp.status_code == 201 - data = resp.json() - assert data["channel_type"] == "web" - assert data["account_id"] == "test-account" - assert data["agent_config_id"] == 1 +class TestBindingsRouter: + def test_create_binding(self, client): + resp = client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "test-account", + "group_id": "test-group", + "agent_config_id": 1, + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert data["channel_type"] == "web" + assert data["account_id"] == "test-account" + assert data["agent_config_id"] == 1 + def test_list_bindings(self, client): + client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "account-1", + "group_id": "", + "agent_config_id": 1, + }, + ) + client.post( + "/channel/bindings", + json={ + "channel_type": "feishu", + "account_id": "account-2", + "group_id": "", + "agent_config_id": 2, + }, + ) -def test_list_bindings(client): - client.post("/channel/bindings", json={ - "channel_type": "web", - "account_id": "account-1", - "group_id": "", - "agent_config_id": 1, - }) - client.post("/channel/bindings", json={ - "channel_type": "feishu", - "account_id": "account-2", - "group_id": "", - "agent_config_id": 2, - }) + resp = client.get("/channel/bindings") + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) == 2 + assert data["meta"]["total"] == 2 - resp = client.get("/channel/bindings") - assert resp.status_code == 200 - data = resp.json() - assert len(data["items"]) == 2 - assert data["meta"]["total"] == 2 + def test_list_bindings_with_filter(self, client): + client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "account-1", + "group_id": "", + "agent_config_id": 1, + }, + ) + client.post( + "/channel/bindings", + json={ + "channel_type": "feishu", + "account_id": "account-2", + "group_id": "", + "agent_config_id": 2, + }, + ) + resp = client.get("/channel/bindings?channel_type=web") + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) == 1 + assert data["items"][0]["channel_type"] == "web" -def test_list_bindings_with_filter(client): - client.post("/channel/bindings", json={ - "channel_type": "web", - "account_id": "account-1", - "group_id": "", - "agent_config_id": 1, - }) - client.post("/channel/bindings", json={ - "channel_type": "feishu", - "account_id": "account-2", - "group_id": "", - "agent_config_id": 2, - }) + def test_list_bindings_with_pagination(self, client): + for i in range(5): + client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": f"account-{i}", + "group_id": "", + "agent_config_id": i + 1, + }, + ) - resp = client.get("/channel/bindings?channel_type=web") - assert resp.status_code == 200 - data = resp.json() - assert len(data["items"]) == 1 - assert data["items"][0]["channel_type"] == "web" + resp = client.get("/channel/bindings?offset=0&limit=2") + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) == 2 + assert data["meta"]["offset"] == 0 + assert data["meta"]["limit"] == 2 + assert data["meta"]["total"] == 5 + resp = client.get("/channel/bindings?offset=2&limit=2") + assert resp.status_code == 200 + data = resp.json() + assert len(data["items"]) == 2 + assert data["meta"]["offset"] == 2 -def test_delete_binding(client): - create_resp = client.post("/channel/bindings", json={ - "channel_type": "web", - "account_id": "test-account", - "group_id": "", - "agent_config_id": 1, - }) - binding_id = create_resp.json()["id"] + def test_delete_binding(self, client): + create_resp = client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "test-account", + "group_id": "", + "agent_config_id": 1, + }, + ) + binding_id = create_resp.json()["id"] - resp = client.delete(f"/channel/bindings/{binding_id}") - assert resp.status_code == 200 - assert resp.json()["ok"] is True + resp = client.delete(f"/channel/bindings/{binding_id}") + assert resp.status_code == 200 + assert resp.json()["ok"] is True + def test_delete_binding_not_found(self, client): + resp = client.delete("/channel/bindings/999") + assert resp.status_code == 404 -def test_delete_binding_not_found(client): - resp = client.delete("/channel/bindings/999") - assert resp.status_code == 404 + def test_create_binding_validation_error(self, client): + resp = client.post( + "/channel/bindings", + json={ + "channel_type": "", + "account_id": "test", + "group_id": "", + "agent_config_id": 1, + }, + ) + assert resp.status_code == 422 + def test_create_binding_invalid_agent_config_id(self, client): + resp = client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "test", + "group_id": "", + "agent_config_id": 0, + }, + ) + assert resp.status_code == 422 -def test_create_binding_validation_error(client): - resp = client.post("/channel/bindings", json={ - "channel_type": "", - "account_id": "test", - "group_id": "", - "agent_config_id": 1, - }) - assert resp.status_code == 422 + def test_create_binding_missing_required_fields(self, client): + resp = client.post( + "/channel/bindings", + json={ + "account_id": "test", + "agent_config_id": 1, + }, + ) + assert resp.status_code == 422 + def test_create_binding_account_id_too_long(self, client): + resp = client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "a" * 65, + "group_id": "", + "agent_config_id": 1, + }, + ) + assert resp.status_code == 422 -def test_create_binding_invalid_agent_config_id(client): - resp = client.post("/channel/bindings", json={ - "channel_type": "web", - "account_id": "test", - "group_id": "", - "agent_config_id": 0, - }) - assert resp.status_code == 422 + def test_list_bindings_empty(self, client): + resp = client.get("/channel/bindings") + assert resp.status_code == 200 + data = resp.json() + assert data["items"] == [] + assert data["meta"]["total"] == 0 + assert data["meta"]["offset"] == 0 + assert data["meta"]["limit"] == 50 + + def test_delete_binding_then_list(self, client): + create_resp = client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "test-account", + "group_id": "", + "agent_config_id": 1, + }, + ) + binding_id = create_resp.json()["id"] + + resp = client.delete(f"/channel/bindings/{binding_id}") + assert resp.status_code == 200 + + resp = client.get("/channel/bindings") + assert resp.status_code == 200 + data = resp.json() + assert data["meta"]["total"] == 0 + + def test_create_binding_response_fields(self, client): + resp = client.post( + "/channel/bindings", + json={ + "channel_type": "web", + "account_id": "test-account", + "group_id": "test-group", + "agent_config_id": 1, + }, + ) + assert resp.status_code == 201 + data = resp.json() + assert "id" in data + assert "is_enabled" in data + assert "created_at" in data + assert data["group_id"] == "test-group" + + def test_list_bindings_limit_too_large(self, client): + resp = client.get("/channel/bindings?limit=201") + assert resp.status_code == 422 + + def test_list_bindings_negative_offset(self, client): + resp = client.get("/channel/bindings?offset=-1") + assert resp.status_code == 422 diff --git a/backend/test/unit/channel/interfaces/rest/router/test_channel_status.py b/backend/test/unit/channel/interfaces/rest/router/test_channel_status.py index 4ed6ca9f..97499416 100644 --- a/backend/test/unit/channel/interfaces/rest/router/test_channel_status.py +++ b/backend/test/unit/channel/interfaces/rest/router/test_channel_status.py @@ -81,75 +81,183 @@ def client(): return TestClient(app) -def test_get_channel_status(client): - resp = client.get("/channel/status") - assert resp.status_code == 200 - data = resp.json() - assert len(data) == 2 +class TestChannelStatusRouter: + def test_get_channel_status(self, client): + resp = client.get("/channel/status") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 2 - web_status = next(s for s in data if s["channel_type"] == "web") - assert web_status["healthy"] is True - assert web_status["active_sessions"] == 5 - assert web_status["ws_connected"] is True - assert web_status["capabilities"]["media"] is True + web_status = next(s for s in data if s["channel_type"] == "web") + assert web_status["healthy"] is True + assert web_status["active_sessions"] == 5 + assert web_status["ws_connected"] is True + assert web_status["capabilities"]["media"] is True - feishu_status = next(s for s in data if s["channel_type"] == "feishu") - assert feishu_status["healthy"] is False - assert feishu_status["active_sessions"] == 0 - assert feishu_status["ws_connected"] is False + feishu_status = next(s for s in data if s["channel_type"] == "feishu") + assert feishu_status["healthy"] is False + assert feishu_status["active_sessions"] == 0 + assert feishu_status["ws_connected"] is False + def test_get_channel_status_no_ws_manager(self): + app = FastAPI() + app.include_router(router) -def test_get_channel_status_no_ws_manager(): - app = FastAPI() - app.include_router(router) + container = _FakeChannelContainer() + container.ws_manager = None + app.state.channel = container - container = _FakeChannelContainer() - container.ws_manager = None - app.state.channel = container + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends - from yuxi.channel.container import get_channel - from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + async def fake_get_channel(request=None): + return container - async def fake_get_channel(request=None): - return container + async def fake_auth(): + return True - async def fake_auth(): - return True + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth - app.dependency_overrides[get_channel] = fake_get_channel - app.dependency_overrides[channel_auth_depends] = fake_auth + client = TestClient(app) + resp = client.get("/channel/status") + assert resp.status_code == 200 + data = resp.json() + for status in data: + assert status["ws_connected"] is None - client = TestClient(app) - resp = client.get("/channel/status") - assert resp.status_code == 200 - data = resp.json() - for status in data: - assert status["ws_connected"] is None + def test_get_channel_status_no_session_repo(self): + app = FastAPI() + app.include_router(router) + container = _FakeChannelContainer() + container.session_repo = None + app.state.channel = container -def test_get_channel_status_no_session_repo(): - app = FastAPI() - app.include_router(router) + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends - container = _FakeChannelContainer() - container.session_repo = None - app.state.channel = container + async def fake_get_channel(request=None): + return container - from yuxi.channel.container import get_channel - from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + async def fake_auth(): + return True - async def fake_get_channel(request=None): - return container + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth - async def fake_auth(): - return True + client = TestClient(app) + resp = client.get("/channel/status") + assert resp.status_code == 200 + data = resp.json() + for status in data: + assert status["active_sessions"] == 0 - app.dependency_overrides[get_channel] = fake_get_channel - app.dependency_overrides[channel_auth_depends] = fake_auth + def test_get_channel_status_empty_adapters(self): + app = FastAPI() + app.include_router(router) - client = TestClient(app) - resp = client.get("/channel/status") - assert resp.status_code == 200 - data = resp.json() - for status in data: - assert status["active_sessions"] == 0 + container = _FakeChannelContainer() + container.adapters = {} + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + client = TestClient(app) + resp = client.get("/channel/status") + assert resp.status_code == 200 + data = resp.json() + assert data == [] + + def test_get_channel_status_capabilities_fields(self, client): + resp = client.get("/channel/status") + assert resp.status_code == 200 + data = resp.json() + + web_status = next(s for s in data if s["channel_type"] == "web") + caps = web_status["capabilities"] + assert "media" in caps + assert "group" in caps + assert "dm" in caps + assert "streaming" in caps + assert "typing" in caps + assert "reaction" in caps + assert "thread" in caps + assert "max_text_length" in caps + assert caps["max_text_length"] == 4096 + + def test_get_channel_status_session_repo_exception(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + + class _FakeSessionRepoError: + async def count_active_sessions(self, channel_type: str) -> int: + raise RuntimeError("db error") + + container.session_repo = _FakeSessionRepoError() + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + client = TestClient(app) + resp = client.get("/channel/status") + assert resp.status_code == 200 + data = resp.json() + for status in data: + assert status["active_sessions"] == 0 + + def test_get_channel_status_single_adapter(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.adapters = { + "web": _FakeAdapter("web", healthy=True), + } + container.session_repo = _FakeSessionRepo({"web": 10}) + container.ws_manager = _FakeWsManager({"web": True}) + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + client = TestClient(app) + resp = client.get("/channel/status") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["channel_type"] == "web" + assert data[0]["healthy"] is True + assert data[0]["active_sessions"] == 10 + assert data[0]["ws_connected"] is True diff --git a/backend/test/unit/channel/interfaces/rest/router/test_config_reload.py b/backend/test/unit/channel/interfaces/rest/router/test_config_reload.py index f58243f7..05a7f89f 100644 --- a/backend/test/unit/channel/interfaces/rest/router/test_config_reload.py +++ b/backend/test/unit/channel/interfaces/rest/router/test_config_reload.py @@ -86,3 +86,110 @@ def test_reload_config_empty_updated(): assert data["status"] == "reloaded" assert data["updated_middlewares"] == [] assert data["config_hash"] == "" + + +class TestConfigReloadRouter: + def test_reload_config(self, client): + resp = client.post("/channel/config/reload") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "reloaded" + assert data["updated_middlewares"] == ["auth_token"] + assert data["config_hash"] == "abc123" + + def test_reload_config_empty_updated(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.config_service = _FakeConfigService(updated=[], config_hash="") + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + client = TestClient(app) + resp = client.post("/channel/config/reload") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "reloaded" + assert data["updated_middlewares"] == [] + assert data["config_hash"] == "" + + def test_reload_config_multiple_middlewares(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.config_service = _FakeConfigService( + updated=["auth_token", "rate_limit", "keyword_filter"], + config_hash="hash456", + ) + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + client = TestClient(app) + resp = client.post("/channel/config/reload") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "reloaded" + assert len(data["updated_middlewares"]) == 3 + assert data["config_hash"] == "hash456" + + def test_reload_config_none_hash(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.config_service = _FakeConfigService( + updated=["auth"], + config_hash=None, + ) + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + client = TestClient(app) + resp = client.post("/channel/config/reload") + assert resp.status_code == 200 + data = resp.json() + assert data["config_hash"] is None + + def test_reload_config_response_fields(self, client): + resp = client.post("/channel/config/reload") + assert resp.status_code == 200 + data = resp.json() + assert "status" in data + assert "updated_middlewares" in data + assert "config_hash" in data + assert isinstance(data["updated_middlewares"], list) diff --git a/backend/test/unit/channel/interfaces/rest/router/test_health.py b/backend/test/unit/channel/interfaces/rest/router/test_health.py index 43cc83d8..be59a627 100644 --- a/backend/test/unit/channel/interfaces/rest/router/test_health.py +++ b/backend/test/unit/channel/interfaces/rest/router/test_health.py @@ -227,3 +227,248 @@ def test_readiness_check_no_adapters(): assert resp.status_code == 503 data = resp.json() assert data["channels"]["status"] == "error" + + +class TestHealthRouter: + def test_liveness_check(self, client): + resp = client.get("/healthz") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["startup_timeline"] is not None + + def test_readiness_check_all_ok(self, client): + resp = client.get("/readyz") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ready" + assert data["redis"]["status"] == "ok" + assert data["workers"]["status"] == "ok" + assert data["channels"]["status"] == "ok" + + def test_readiness_check_redis_down(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.redis = _FakeRedis(ping_ok=False) + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 503 + data = resp.json() + assert data["status"] == "not_ready" + assert data["redis"]["status"] == "error" + + def test_readiness_check_workers_not_running(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.worker_pool = _FakeWorkerPool(running=False) + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 503 + data = resp.json() + assert data["workers"]["status"] == "error" + + def test_readiness_check_unhealthy_channel(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.adapters = { + "web": _FakeAdapter(healthy=True), + "feishu": _FakeAdapter(healthy=False), + } + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 503 + data = resp.json() + assert data["channels"]["status"] == "degraded" + assert "feishu" in data["channels"]["detail"] + + def test_readiness_check_ws_disconnected(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.ws_manager = _FakeWsManager({"feishu": False}) + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 503 + data = resp.json() + assert data["ws_connections"]["status"] == "degraded" + assert "feishu" in data["ws_connections"]["detail"] + + def test_readiness_check_no_ws_manager(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.ws_manager = None + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 200 + data = resp.json() + assert data["ws_connections"]["status"] == "ok" + + def test_readiness_check_no_adapters(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.adapters = {} + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 503 + data = resp.json() + assert data["channels"]["status"] == "error" + + def test_liveness_check_no_container(self): + app = FastAPI() + app.include_router(router) + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return None + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/healthz") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] == "ok" + assert data["startup_timeline"] is None + + def test_readiness_check_no_container(self): + app = FastAPI() + app.include_router(router) + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return None + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 503 + data = resp.json() + assert data["status"] == "not_ready" + + def test_readiness_check_all_channels_healthy(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.adapters = { + "web": _FakeAdapter(healthy=True), + "feishu": _FakeAdapter(healthy=True), + "dingtalk": _FakeAdapter(healthy=True), + } + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 200 + data = resp.json() + assert data["channels"]["status"] == "ok" + + def test_readiness_check_ws_all_connected(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.ws_manager = _FakeWsManager({"web": True, "feishu": True}) + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/readyz") + assert resp.status_code == 200 + data = resp.json() + assert data["ws_connections"]["status"] == "ok" + + def test_readiness_check_response_structure(self, client): + resp = client.get("/readyz") + assert resp.status_code == 200 + data = resp.json() + assert "status" in data + assert "redis" in data + assert "postgres" in data + assert "workers" in data + assert "channels" in data + assert "ws_connections" in data + for key in ["redis", "postgres", "workers", "channels", "ws_connections"]: + if data[key]: + assert "status" in data[key] diff --git a/backend/test/unit/channel/interfaces/rest/router/test_metrics.py b/backend/test/unit/channel/interfaces/rest/router/test_metrics.py index af012ffe..545e6b96 100644 --- a/backend/test/unit/channel/interfaces/rest/router/test_metrics.py +++ b/backend/test/unit/channel/interfaces/rest/router/test_metrics.py @@ -85,3 +85,107 @@ def test_metrics_endpoint_without_metrics(): client = TestClient(app) resp = client.get("/metrics") assert resp.status_code == 200 + + +class TestMetricsRouter: + def test_metrics_endpoint(self, client): + resp = client.get("/metrics") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/plain; version=0.0.4; charset=utf-8" + + def test_metrics_endpoint_without_sse_endpoint(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.sse_endpoint = None + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/metrics") + assert resp.status_code == 200 + + def test_metrics_endpoint_without_metrics(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.metrics = None + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/metrics") + assert resp.status_code == 200 + + def test_metrics_endpoint_no_container(self): + app = FastAPI() + app.include_router(router) + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return None + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/metrics") + assert resp.status_code == 200 + + def test_metrics_endpoint_zero_connections(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.sse_endpoint = _FakeSseEndpoint(count=0) + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/metrics") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/plain; version=0.0.4; charset=utf-8" + + def test_metrics_endpoint_large_connection_count(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.sse_endpoint = _FakeSseEndpoint(count=9999) + app.state.channel = container + + from yuxi.channel.container import get_channel + + async def fake_get_channel(request=None): + return container + + app.dependency_overrides[get_channel] = fake_get_channel + + client = TestClient(app) + resp = client.get("/metrics") + assert resp.status_code == 200 + + def test_metrics_content_not_empty(self, client): + resp = client.get("/metrics") + assert resp.status_code == 200 + assert len(resp.content) > 0 diff --git a/backend/test/unit/channel/interfaces/rest/router/test_plugin_registry.py b/backend/test/unit/channel/interfaces/rest/router/test_plugin_registry.py new file mode 100644 index 00000000..edf584ec --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/test_plugin_registry.py @@ -0,0 +1,345 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from yuxi.channel.interfaces.rest.router.plugin_registry import router +from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId +from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest +from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginSource, PluginOrigin +from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + + +class _FakeRegistry: + def __init__(self, records: dict[str, PluginRecord] | None = None): + self._records = records or {} + self._adapters: dict[str, object] = {} + + def get_record(self, plugin_id: str) -> PluginRecord | None: + return self._records.get(plugin_id) + + def list_records(self, spec=None) -> list[PluginRecord]: + records = list(self._records.values()) + if spec: + records = [r for r in records if spec.is_satisfied_by(r)] + return records + + async def activate(self, plugin_id: str, **config) -> None: + record = self._records.get(plugin_id) + if record: + record.status = PluginStatus.ACTIVE + + async def deactivate(self, plugin_id: str) -> None: + record = self._records.get(plugin_id) + if record: + record.status = PluginStatus.DISABLED + + async def reload(self, plugin_id: str, **config) -> None: + record = self._records.get(plugin_id) + if record: + record.version = "2.0.0" + + def get_adapter(self, channel_type: str) -> object | None: + return self._adapters.get(channel_type) + + +class _FakeRegistryImpl: + def __init__(self, records: dict[str, PluginRecord] | None = None): + self.registry = _FakeRegistry(records) + self._records = records or {} + + def get_record(self, plugin_id: str) -> PluginRecord | None: + return self._records.get(plugin_id) + + def _resolve_config(self, record: PluginRecord) -> dict: + return {} + + +class _FakeEventPublisher: + async def publish(self, event) -> None: + pass + + +class _FakeChannelContainer: + def __init__(self, records: dict[str, PluginRecord] | None = None): + self.adapters: dict[str, object] = {} + self.event_publisher = _FakeEventPublisher() + self._plugin_app_service = None + self._records = records or {} + + +class _FakeAuthService: + async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]: + return True, "" + + +def _make_record( + plugin_id: str, + name: str = "Test Plugin", + version: str = "1.0.0", + channel_type: str = "web", + status: PluginStatus = PluginStatus.REGISTERED, + config_schema: dict | None = None, +) -> PluginRecord: + return PluginRecord( + record_id=PluginRecordId.generate(), + plugin_id=plugin_id, + name=name, + version=version, + channel_type=channel_type, + source=PluginSource( + origin=PluginOrigin.BUNDLED, + root_dir="/tmp", + entry_file="", + ), + manifest=PluginManifest( + id=plugin_id, + name=name, + version=version, + channel_type=channel_type, + capabilities=ChannelCapabilities(), + config_schema=config_schema, + ), + capabilities=ChannelCapabilities(), + config_schema=config_schema, + status=status, + ) + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + + records = { + "plugin-web": _make_record("plugin-web", status=PluginStatus.ACTIVE), + "plugin-feishu": _make_record("plugin-feishu", channel_type="feishu", status=PluginStatus.DISABLED), + } + container = _FakeChannelContainer(records=records) + container._records = records + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + from yuxi.channel.infrastructure.plugin.registry_impl import get_registry, set_registry + + registry_impl = _FakeRegistryImpl(records) + set_registry(registry_impl) + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + return TestClient(app) + + +class TestPluginRegistryRouter: + def test_list_plugins(self, client): + resp = client.get("/channel/plugins") + assert resp.status_code == 200 + data = resp.json() + assert "plugins" in data + assert "total" in data + assert data["total"] == 2 + + def test_list_plugins_with_origin_filter(self, client): + resp = client.get("/channel/plugins?origin=bundled") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + + def test_list_plugins_with_status_filter(self, client): + resp = client.get("/channel/plugins?status=active") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 1 + assert data["plugins"][0]["status"] == "active" + + def test_get_plugin(self, client): + resp = client.get("/channel/plugins/plugin-web") + assert resp.status_code == 200 + data = resp.json() + assert data["plugin_id"] == "plugin-web" + assert data["name"] == "Test Plugin" + assert data["channel_type"] == "web" + assert "capabilities" in data + assert "config_schema" in data + + def test_get_plugin_not_found(self, client): + resp = client.get("/channel/plugins/nonexistent") + assert resp.status_code == 404 + + def test_activate_plugin(self, client): + records = { + "plugin-dingtalk": _make_record("plugin-dingtalk", channel_type="dingtalk", status=PluginStatus.REGISTERED), + } + from yuxi.channel.infrastructure.plugin.registry_impl import set_registry + + set_registry(_FakeRegistryImpl(records)) + + resp = client.post("/channel/plugins/plugin-dingtalk/activate") + assert resp.status_code == 200 + data = resp.json() + assert data["plugin_id"] == "plugin-dingtalk" + + def test_activate_plugin_not_found(self, client): + resp = client.post("/channel/plugins/nonexistent/activate") + assert resp.status_code == 404 + + def test_deactivate_plugin(self, client): + records = { + "plugin-web": _make_record("plugin-web", status=PluginStatus.ACTIVE), + } + from yuxi.channel.infrastructure.plugin.registry_impl import set_registry + + set_registry(_FakeRegistryImpl(records)) + + resp = client.post("/channel/plugins/plugin-web/deactivate") + assert resp.status_code == 200 + data = resp.json() + assert data["plugin_id"] == "plugin-web" + + def test_deactivate_plugin_not_found(self, client): + resp = client.post("/channel/plugins/nonexistent/deactivate") + assert resp.status_code == 404 + + def test_reload_plugin(self, client): + records = { + "plugin-web": _make_record("plugin-web", status=PluginStatus.ACTIVE), + } + from yuxi.channel.infrastructure.plugin.registry_impl import set_registry + + set_registry(_FakeRegistryImpl(records)) + + resp = client.post("/channel/plugins/plugin-web/reload") + assert resp.status_code == 200 + data = resp.json() + assert data["plugin_id"] == "plugin-web" + assert "old_version" in data + assert "new_version" in data + assert "success" in data + + def test_reload_plugin_not_found(self, client): + resp = client.post("/channel/plugins/nonexistent/reload") + assert resp.status_code == 404 + + def test_get_plugin_manifest(self, client): + resp = client.get("/channel/plugins/plugin-web/manifest") + assert resp.status_code == 200 + data = resp.json() + assert "id" in data + assert "name" in data + assert "version" in data + assert "capabilities" in data + + def test_get_plugin_manifest_not_found(self, client): + resp = client.get("/channel/plugins/nonexistent/manifest") + assert resp.status_code == 404 + + def test_get_plugin_config_schema(self, client): + records = { + "plugin-config": _make_record( + "plugin-config", + config_schema={"type": "object", "required": ["token"]}, + ), + } + from yuxi.channel.infrastructure.plugin.registry_impl import set_registry + + set_registry(_FakeRegistryImpl(records)) + + resp = client.get("/channel/plugins/plugin-config/config-schema") + assert resp.status_code == 200 + data = resp.json() + assert data["config_schema"] is not None + assert "type" in data["config_schema"] + + def test_get_plugin_config_schema_none(self, client): + records = { + "plugin-no-config": _make_record("plugin-no-config", config_schema=None), + } + from yuxi.channel.infrastructure.plugin.registry_impl import set_registry + + set_registry(_FakeRegistryImpl(records)) + + resp = client.get("/channel/plugins/plugin-no-config/config-schema") + assert resp.status_code == 200 + data = resp.json() + assert data["config_schema"] is None + + def test_get_plugin_config_schema_not_found(self, client): + resp = client.get("/channel/plugins/nonexistent/config-schema") + assert resp.status_code == 404 + + def test_list_plugins_empty(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer(records={}) + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + from yuxi.channel.infrastructure.plugin.registry_impl import set_registry + + set_registry(_FakeRegistryImpl({})) + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + client = TestClient(app) + resp = client.get("/channel/plugins") + assert resp.status_code == 200 + data = resp.json() + assert data["plugins"] == [] + assert data["total"] == 0 + + def test_activate_plugin_with_config_override(self, client): + records = { + "plugin-override": _make_record( + "plugin-override", + channel_type="web", + status=PluginStatus.REGISTERED, + config_schema={"type": "object", "required": ["token"]}, + ), + } + from yuxi.channel.infrastructure.plugin.registry_impl import set_registry + + set_registry(_FakeRegistryImpl(records)) + + resp = client.post( + "/channel/plugins/plugin-override/activate", + json={"config_override": {"token": "secret123"}}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["plugin_id"] == "plugin-override" + + def test_reload_plugin_with_force(self, client): + records = { + "plugin-force": _make_record("plugin-force", status=PluginStatus.ACTIVE), + } + from yuxi.channel.infrastructure.plugin.registry_impl import set_registry + + set_registry(_FakeRegistryImpl(records)) + + resp = client.post( + "/channel/plugins/plugin-force/reload", + json={"force": True}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data["plugin_id"] == "plugin-force" + assert data["success"] is True diff --git a/backend/test/unit/channel/interfaces/rest/router/test_registry.py b/backend/test/unit/channel/interfaces/rest/router/test_registry.py index b198d310..08bc4f46 100644 --- a/backend/test/unit/channel/interfaces/rest/router/test_registry.py +++ b/backend/test/unit/channel/interfaces/rest/router/test_registry.py @@ -4,7 +4,7 @@ import pytest from fastapi import FastAPI, APIRouter from yuxi.channel.interfaces.rest.router.registry import register_all_channel_routes, _get_route_contributor -from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor +from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor class _FakeContributor: @@ -77,3 +77,85 @@ def test_get_route_contributor_with_router_attr(): contributor = _get_route_contributor(adapter) assert contributor is not None assert hasattr(contributor, "router") + + +class TestRegistryRouter: + def test_register_all_channel_routes(self): + app = FastAPI() + adapters = { + "web": _FakeAdapterWithContributor(), + "feishu": _FakeAdapterWithContributor(), + } + + registered = register_all_channel_routes(app, adapters) + assert len(registered) == 2 + assert "/channel/web" in registered + assert "/channel/feishu" in registered + + def test_register_all_channel_routes_skips_none_contributor(self): + app = FastAPI() + adapters = { + "web": _FakeAdapterWithContributor(), + "dingtalk": _FakeAdapterWithoutContributor(), + } + + registered = register_all_channel_routes(app, adapters) + assert len(registered) == 1 + assert "/channel/web" in registered + assert "/channel/dingtalk" not in registered + + def test_get_route_contributor_with_contributor(self): + adapter = _FakeAdapterWithContributor() + contributor = _get_route_contributor(adapter) + assert contributor is not None + + def test_get_route_contributor_without_contributor(self): + adapter = _FakeAdapterWithoutContributor() + contributor = _get_route_contributor(adapter) + assert contributor is None + + def test_get_route_contributor_with_router_attr(self): + adapter = _FakeAdapterWithRouterAttr() + contributor = _get_route_contributor(adapter) + assert contributor is not None + assert hasattr(contributor, "router") + + def test_register_all_channel_routes_empty_adapters(self): + app = FastAPI() + adapters = {} + + registered = register_all_channel_routes(app, adapters) + assert len(registered) == 0 + + def test_register_all_channel_routes_single_adapter(self): + app = FastAPI() + adapters = { + "web": _FakeAdapterWithContributor(), + } + + registered = register_all_channel_routes(app, adapters) + assert len(registered) == 1 + assert "/channel/web" in registered + + def test_register_all_channel_routes_mixed_contributors(self): + app = FastAPI() + adapters = { + "web": _FakeAdapterWithContributor(), + "feishu": _FakeAdapterWithoutContributor(), + "dingtalk": _FakeAdapterWithRouterAttr(), + } + + registered = register_all_channel_routes(app, adapters) + assert len(registered) == 2 + assert "/channel/web" in registered + assert "/channel/dingtalk" in registered + assert "/channel/feishu" not in registered + + def test_register_all_channel_routes_returns_list(self): + app = FastAPI() + adapters = { + "web": _FakeAdapterWithContributor(), + } + + registered = register_all_channel_routes(app, adapters) + assert isinstance(registered, list) diff --git a/backend/test/unit/channel/interfaces/rest/router/test_sse.py b/backend/test/unit/channel/interfaces/rest/router/test_sse.py index 0cb915cb..392c0cff 100644 --- a/backend/test/unit/channel/interfaces/rest/router/test_sse.py +++ b/backend/test/unit/channel/interfaces/rest/router/test_sse.py @@ -13,8 +13,10 @@ class _FakeSseEndpoint: async def subscribe(self, session_id: str, request): from fastapi.responses import StreamingResponse + async def event_stream(): yield 'event: connected\ndata: {"session_id": "test"}\n\n' + return StreamingResponse(event_stream(), media_type="text/event-stream") @@ -91,3 +93,82 @@ def test_subscribe_sse_no_sse_endpoint(): def test_subscribe_sse_missing_session_id(client): resp = client.get("/channel/sse") assert resp.status_code == 422 + + +class TestSseRouter: + def test_subscribe_sse_valid_session_id(self, client): + resp = client.get("/channel/sse?session_id=valid-session-id") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/event-stream; charset=utf-8" + + def test_subscribe_sse_invalid_session_id(self, client): + resp = client.get("/channel/sse?session_id=invalid@session") + assert resp.status_code == 400 + + def test_subscribe_sse_no_sse_endpoint(self): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.sse_endpoint = None + app.state.channel = container + + from yuxi.channel.container import get_channel + from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + + async def fake_get_channel(request=None): + return container + + async def fake_auth(): + return True + + app.dependency_overrides[get_channel] = fake_get_channel + app.dependency_overrides[channel_auth_depends] = fake_auth + + client = TestClient(app) + resp = client.get("/channel/sse?session_id=valid-session-id") + assert resp.status_code == 503 + + def test_subscribe_sse_missing_session_id(self, client): + resp = client.get("/channel/sse") + assert resp.status_code == 422 + + def test_subscribe_sse_empty_session_id(self, client): + resp = client.get("/channel/sse?session_id=") + assert resp.status_code == 400 + + def test_subscribe_sse_session_id_too_long(self, client): + long_id = "a" * 129 + resp = client.get(f"/channel/sse?session_id={long_id}") + assert resp.status_code == 400 + + def test_subscribe_sse_session_id_with_underscore(self, client): + resp = client.get("/channel/sse?session_id=test_session_123") + assert resp.status_code == 200 + + def test_subscribe_sse_session_id_with_hyphen(self, client): + resp = client.get("/channel/sse?session_id=test-session-123") + assert resp.status_code == 200 + + def test_subscribe_sse_session_id_with_numbers(self, client): + resp = client.get("/channel/sse?session_id=123456789") + assert resp.status_code == 200 + + def test_subscribe_sse_session_id_mixed(self, client): + resp = client.get("/channel/sse?session_id=Test-123_test") + assert resp.status_code == 200 + + def test_subscribe_sse_special_chars_rejected(self, client): + resp = client.get("/channel/sse?session_id=test.session") + assert resp.status_code == 400 + + def test_subscribe_sse_unicode_rejected(self, client): + resp = client.get("/channel/sse?session_id=test中文") + assert resp.status_code == 400 + + def test_subscribe_sse_streaming_response(self, client): + resp = client.get("/channel/sse?session_id=stream-test") + assert resp.status_code == 200 + assert resp.headers["content-type"] == "text/event-stream; charset=utf-8" + content = resp.content.decode("utf-8") + assert "event: connected" in content diff --git a/backend/test/unit/channel/interfaces/sse/__init__.py b/backend/test/unit/channel/interfaces/sse/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/interfaces/sse/__init__.py +++ b/backend/test/unit/channel/interfaces/sse/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/interfaces/sse/test_endpoint.py b/backend/test/unit/channel/interfaces/sse/test_endpoint.py index a42ee575..284558e1 100644 --- a/backend/test/unit/channel/interfaces/sse/test_endpoint.py +++ b/backend/test/unit/channel/interfaces/sse/test_endpoint.py @@ -65,6 +65,7 @@ async def test_sse_endpoint_subscribe_limit_reached(endpoint): # Second connection should fail from fastapi import HTTPException + with pytest.raises(HTTPException) as exc_info: await endpoint.subscribe("session-2", request) assert exc_info.value.status_code == 503 @@ -113,6 +114,7 @@ async def test_sse_endpoint_broadcast_shutdown(endpoint): async def test_sse_endpoint_cleanup_stale(endpoint): await endpoint.start() import time + old_conn = _SseConnection(session_id="session-1", queue=asyncio.Queue(), last_active_at=time.monotonic() - 400) endpoint._connections["session-1"] = [old_conn] diff --git a/backend/test/unit/channel/interfaces/websocket/__init__.py b/backend/test/unit/channel/interfaces/websocket/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/interfaces/websocket/__init__.py +++ b/backend/test/unit/channel/interfaces/websocket/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/interfaces/websocket/test_manager.py b/backend/test/unit/channel/interfaces/websocket/test_manager.py index a54816a6..fb52706d 100644 --- a/backend/test/unit/channel/interfaces/websocket/test_manager.py +++ b/backend/test/unit/channel/interfaces/websocket/test_manager.py @@ -62,6 +62,7 @@ class _FakeAdapter(ChannelAdapterPort): async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict): from yuxi.channel.domain.model.message.dispatch_result import SendResult + return SendResult(success=True) async def send_typing(self, session_id: str) -> None: @@ -76,6 +77,7 @@ class _FakeAdapter(ChannelAdapterPort): @property def capabilities(self): from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + return ChannelCapabilities() @classmethod diff --git a/backend/test/unit/channel/startup/test_agent_adapter.py b/backend/test/unit/channel/startup/test_agent_adapter.py deleted file mode 100644 index 2073cbe1..00000000 --- a/backend/test/unit/channel/startup/test_agent_adapter.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.infrastructure.agent.agent_adapter import AgentAdapter - - -class TestAgentAdapter: - @pytest.fixture - def mock_agent_port(self): - return AsyncMock() - - @pytest.fixture - def adapter(self, mock_agent_port): - return AgentAdapter(agent_port=mock_agent_port) - - @pytest.mark.asyncio - async def test_stream_chat(self, adapter, mock_agent_port): - mock_agent_port.stream_chat.return_value = "Hello, user!" - result = await adapter.stream_chat( - thread_id="thread123", - message="Hello", - agent_config_id=1, - ) - assert result == "Hello, user!" - mock_agent_port.stream_chat.assert_awaited_once() - - @pytest.mark.asyncio - async def test_stream_chat_with_history(self, adapter, mock_agent_port): - mock_agent_port.stream_chat.return_value = "Hello, user!" - result = await adapter.stream_chat( - thread_id="thread123", - message="Hello", - agent_config_id=1, - history=[{"role": "user", "content": "Hi"}], - ) - assert result == "Hello, user!" - - @pytest.mark.asyncio - async def test_stream_chat_exception(self, adapter, mock_agent_port): - mock_agent_port.stream_chat.side_effect = Exception("agent error") - with pytest.raises(Exception): - await adapter.stream_chat( - thread_id="thread123", - message="Hello", - agent_config_id=1, - ) - - @pytest.mark.asyncio - async def test_get_agent_info(self, adapter, mock_agent_port): - mock_agent_port.get_agent_info.return_value = {"name": "Test Agent"} - result = await adapter.get_agent_info(agent_config_id=1) - assert result == {"name": "Test Agent"} - - @pytest.mark.asyncio - async def test_health_check(self, adapter, mock_agent_port): - mock_agent_port.health_check.return_value = True - result = await adapter.health_check() - assert result is True - - @pytest.mark.asyncio - async def test_health_check_failure(self, adapter, mock_agent_port): - mock_agent_port.health_check.return_value = False - result = await adapter.health_check() - assert result is False diff --git a/backend/test/unit/channel/startup/test_aho_corasick_matcher.py b/backend/test/unit/channel/startup/test_aho_corasick_matcher.py deleted file mode 100644 index e9c479b4..00000000 --- a/backend/test/unit/channel/startup/test_aho_corasick_matcher.py +++ /dev/null @@ -1,45 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.infrastructure.matching.aho_corasick_matcher import AhoCorasickMatcher - - -class TestAhoCorasickMatcher: - @pytest.fixture - def matcher(self): - return AhoCorasickMatcher(keywords=["bad", "worse", "worst"]) - - def test_match_found(self, matcher): - result = matcher.match("This is bad behavior") - assert result is True - - def test_match_not_found(self, matcher): - result = matcher.match("This is good behavior") - assert result is False - - def test_match_multiple(self, matcher): - result = matcher.match("This is bad and worse") - assert result is True - - def test_match_empty_text(self, matcher): - result = matcher.match("") - assert result is False - - def test_match_empty_keywords(self): - matcher = AhoCorasickMatcher(keywords=[]) - result = matcher.match("This is bad") - assert result is False - - def test_match_case_sensitive(self, matcher): - result = matcher.match("This is BAD") - assert result is False - - def test_match_partial_word(self, matcher): - result = matcher.match("This is badly behaved") - assert result is True - - def test_add_keywords(self, matcher): - matcher.add_keywords(["new_bad"]) - result = matcher.match("This is new_bad") - assert result is True diff --git a/backend/test/unit/channel/startup/test_auth_service.py b/backend/test/unit/channel/startup/test_auth_service.py deleted file mode 100644 index a80df6c3..00000000 --- a/backend/test/unit/channel/startup/test_auth_service.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.application.service.auth_service import AuthService - - -class TestAuthService: - @pytest.fixture - def mock_rate_limiter(self): - return AsyncMock() - - @pytest.fixture - def auth_service(self, mock_rate_limiter): - return AuthService( - mock_rate_limiter, - token="test_token", - password="test_password", - max_attempts=3, - lockout_seconds=300, - ) - - @pytest.mark.asyncio - async def test_authenticate_no_credentials_configured(self, mock_rate_limiter): - service = AuthService(mock_rate_limiter) - passed, reason = await service.authenticate("") - assert passed is True - assert reason == "" - - @pytest.mark.asyncio - async def test_authenticate_with_valid_bearer_token(self, auth_service, mock_rate_limiter): - mock_rate_limiter.is_locked.return_value = (False, 0) - passed, reason = await auth_service.authenticate("Bearer test_token") - assert passed is True - assert reason == "" - mock_rate_limiter.reset.assert_awaited_once() - - @pytest.mark.asyncio - async def test_authenticate_with_invalid_bearer_token(self, auth_service, mock_rate_limiter): - mock_rate_limiter.is_locked.return_value = (False, 0) - mock_rate_limiter.check_and_incr.return_value = True - passed, reason = await auth_service.authenticate("Bearer wrong_token") - assert passed is False - assert reason == "auth failed" - - @pytest.mark.asyncio - async def test_authenticate_with_valid_basic_password(self, auth_service, mock_rate_limiter): - import base64 - mock_rate_limiter.is_locked.return_value = (False, 0) - credentials = base64.b64encode(b"user:test_password").decode() - passed, reason = await auth_service.authenticate(f"Basic {credentials}") - assert passed is True - assert reason == "" - - @pytest.mark.asyncio - async def test_authenticate_with_invalid_basic_password(self, auth_service, mock_rate_limiter): - import base64 - mock_rate_limiter.is_locked.return_value = (False, 0) - mock_rate_limiter.check_and_incr.return_value = True - credentials = base64.b64encode(b"user:wrong_password").decode() - passed, reason = await auth_service.authenticate(f"Basic {credentials}") - assert passed is False - - @pytest.mark.asyncio - async def test_authenticate_when_locked(self, auth_service, mock_rate_limiter): - mock_rate_limiter.is_locked.return_value = (True, 120) - passed, reason = await auth_service.authenticate("Bearer test_token") - assert passed is False - assert "retry after" in reason - - @pytest.mark.asyncio - async def test_authenticate_rate_limited(self, auth_service, mock_rate_limiter): - mock_rate_limiter.is_locked.return_value = (False, 0) - mock_rate_limiter.check_and_incr.return_value = False - passed, reason = await auth_service.authenticate("Bearer wrong_token") - assert passed is False - assert reason == "auth rate limited" - - def test_update_credentials(self, auth_service): - auth_service.update_credentials(token="new_token", password="new_password") - assert auth_service._token == "new_token" - assert auth_service._password == "new_password" - - @pytest.mark.asyncio - async def test_authenticate_empty_header_with_credentials(self, auth_service, mock_rate_limiter): - mock_rate_limiter.is_locked.return_value = (False, 0) - mock_rate_limiter.check_and_incr.return_value = True - passed, reason = await auth_service.authenticate("") - assert passed is False - - @pytest.mark.asyncio - async def test_authenticate_malformed_basic(self, auth_service, mock_rate_limiter): - mock_rate_limiter.is_locked.return_value = (False, 0) - mock_rate_limiter.check_and_incr.return_value = True - passed, reason = await auth_service.authenticate("Basic invalid_base64!!!") - assert passed is False diff --git a/backend/test/unit/channel/startup/test_binding_service.py b/backend/test/unit/channel/startup/test_binding_service.py deleted file mode 100644 index aa50b623..00000000 --- a/backend/test/unit/channel/startup/test_binding_service.py +++ /dev/null @@ -1,85 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.application.service.binding_service import BindingService -from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding - - -class TestBindingService: - @pytest.fixture - def mock_repo(self): - return AsyncMock() - - @pytest.fixture - def binding_service(self, mock_repo): - return BindingService(mock_repo) - - @pytest.mark.asyncio - async def test_create(self, binding_service, mock_repo): - mock_repo.create_binding.return_value = ChannelBinding( - id=1, - channel_type="web", - account_id="user1", - group_id="", - agent_config_id=1, - ) - result = await binding_service.create( - channel_type="web", - account_id="user1", - group_id="", - agent_config_id=1, - ) - assert result.id == 1 - mock_repo.create_binding.assert_awaited_once() - - @pytest.mark.asyncio - async def test_delete(self, binding_service, mock_repo): - mock_repo.delete_binding.return_value = True - result = await binding_service.delete(1) - assert result is True - mock_repo.delete_binding.assert_awaited_once_with(1) - - @pytest.mark.asyncio - async def test_get(self, binding_service, mock_repo): - mock_repo.get_binding.return_value = ChannelBinding( - id=1, - channel_type="web", - account_id="user1", - group_id="", - agent_config_id=1, - ) - result = await binding_service.get(1) - assert result is not None - assert result.id == 1 - - @pytest.mark.asyncio - async def test_get_none(self, binding_service, mock_repo): - mock_repo.get_binding.return_value = None - result = await binding_service.get(1) - assert result is None - - @pytest.mark.asyncio - async def test_list(self, binding_service, mock_repo): - mock_repo.list_bindings.return_value = ( - [ - ChannelBinding(id=1, channel_type="web", account_id="user1", group_id="", agent_config_id=1), - ChannelBinding(id=2, channel_type="feishu", account_id="user2", group_id="", agent_config_id=2), - ], - 2, - ) - results, total = await binding_service.list() - assert len(results) == 2 - assert total == 2 - - @pytest.mark.asyncio - async def test_list_with_filter(self, binding_service, mock_repo): - mock_repo.list_bindings.return_value = ( - [ChannelBinding(id=1, channel_type="web", account_id="user1", group_id="", agent_config_id=1)], - 1, - ) - results, total = await binding_service.list(channel_type="web") - assert len(results) == 1 - mock_repo.list_bindings.assert_awaited_once_with(channel_type="web", offset=0, limit=50) diff --git a/backend/test/unit/channel/startup/test_channel_binding.py b/backend/test/unit/channel/startup/test_channel_binding.py deleted file mode 100644 index a685f4ed..00000000 --- a/backend/test/unit/channel/startup/test_channel_binding.py +++ /dev/null @@ -1,70 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding - - -class TestChannelBinding: - def test_basic_creation(self): - binding = ChannelBinding( - id=1, - channel_type="web", - account_id="user1", - group_id="", - agent_config_id=1, - ) - assert binding.id == 1 - assert binding.channel_type == "web" - assert binding.account_id == "user1" - assert binding.group_id == "" - assert binding.agent_config_id == 1 - - def test_creation_with_optional_fields(self): - binding = ChannelBinding( - id=1, - channel_type="web", - account_id="user1", - group_id="group1", - agent_config_id=1, - session_key_strategy="main", - ) - assert binding.group_id == "group1" - assert binding.session_key_strategy == "main" - - def test_to_dict(self): - binding = ChannelBinding( - id=1, - channel_type="web", - account_id="user1", - group_id="", - agent_config_id=1, - ) - d = binding.to_dict() - assert d["id"] == 1 - assert d["channel_type"] == "web" - assert d["account_id"] == "user1" - assert d["agent_config_id"] == 1 - - def test_from_dict(self): - d = { - "id": 1, - "channel_type": "web", - "account_id": "user1", - "group_id": "", - "agent_config_id": 1, - } - binding = ChannelBinding.from_dict(d) - assert binding.id == 1 - assert binding.channel_type == "web" - assert binding.account_id == "user1" - - def test_default_values(self): - binding = ChannelBinding( - id=1, - channel_type="web", - account_id="user1", - group_id="", - agent_config_id=1, - ) - assert binding.session_key_strategy is None diff --git a/backend/test/unit/channel/startup/test_channel_config.py b/backend/test/unit/channel/startup/test_channel_config.py deleted file mode 100644 index 34ee60d0..00000000 --- a/backend/test/unit/channel/startup/test_channel_config.py +++ /dev/null @@ -1,195 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.infrastructure.config.channel_config import ChannelConfig - - -class TestChannelConfig: - @pytest.fixture - def config_file(self, tmp_path): - yaml_path = tmp_path / "channel_config.yaml" - yaml_path.write_text(""" -auth: - token: "test_token" - password: "test_password" - max_attempts: 3 - lockout_seconds: 600 - -feishu: - mode: "webhook" - app_id: "test_app_id" - app_secret: "test_app_secret" - verification_token: "test_verification_token" - ws_enabled: true - -dingtalk: - mode: "webhook" - client_id: "test_client_id" - client_secret: "test_client_secret" - ws_enabled: true - -web: - sse_enabled: true - max_connections: 500 - -hooks: - mappings: - - match_path: "/test" - default_agent_id: 1 - -worker: - num_workers: 2 - max_concurrent: 10 - poll_timeout_ms: 3000 - outbox_poll_interval: 3.0 - default_agent_config_id: 2 - -access_policies: - web: - dm_policy: "allowlist" - allow_from: - - "user1" - -allow_from: - web: - - "user1" - - "user2" - -keyword_blocklist: - - "badword1" - - "badword2" - -mention_gate: - default_require_mention: false - mention_patterns: - - "@bot" -""") - return str(yaml_path) - - def test_load_config(self, config_file): - config = ChannelConfig(config_file) - assert config.auth_token == "test_token" - assert config.auth_password == "test_password" - assert config.max_auth_attempts == 3 - assert config.lockout_seconds == 600 - - def test_feishu_config(self, config_file): - config = ChannelConfig(config_file) - feishu = config.get_channel_config("feishu") - assert feishu["mode"] == "webhook" - assert feishu["app_id"] == "test_app_id" - - def test_dingtalk_config(self, config_file): - config = ChannelConfig(config_file) - dingtalk = config.get_channel_config("dingtalk") - assert dingtalk["mode"] == "webhook" - assert dingtalk["client_id"] == "test_client_id" - - def test_web_config(self, config_file): - config = ChannelConfig(config_file) - web = config.get_channel_config("web") - assert web["sse_enabled"] is True - assert web["max_connections"] == 500 - - def test_hook_mappings(self, config_file): - config = ChannelConfig(config_file) - mappings = config.hook_mappings - assert len(mappings) == 1 - assert mappings[0]["match_path"] == "/test" - - def test_access_policies(self, config_file): - config = ChannelConfig(config_file) - policies = config.access_policies - assert "web" in policies - - def test_allow_from(self, config_file): - config = ChannelConfig(config_file) - allow_from = config.allow_from - assert "web" in allow_from - - def test_keyword_blocklist(self, config_file): - config = ChannelConfig(config_file) - blocklist = config.keyword_blocklist - assert "badword1" in blocklist - assert "badword2" in blocklist - - def test_mention_gate_config(self, config_file): - config = ChannelConfig(config_file) - mention_config = config.mention_gate_config - assert mention_config["default_require_mention"] is False - assert "@bot" in mention_config["mention_patterns"] - - def test_feishu_verification_token(self, config_file): - config = ChannelConfig(config_file) - assert config.feishu_verification_token == "test_verification_token" - - def test_feishu_ws_config_webhook_mode(self, config_file): - config = ChannelConfig(config_file) - assert config.feishu_ws_config is None - - def test_dingtalk_ws_config_webhook_mode(self, config_file): - config = ChannelConfig(config_file) - assert config.dingtalk_ws_config is None - - def test_raw_data(self, config_file): - config = ChannelConfig(config_file) - raw = config.raw_data - assert "auth" in raw - assert "feishu" in raw - - @pytest.mark.asyncio - async def test_reload(self, config_file): - config = ChannelConfig(config_file) - await config.reload() - assert config.auth_token == "test_token" - - @pytest.mark.asyncio - async def test_on_config_updated(self, config_file): - config = ChannelConfig(config_file) - updated = await config.on_config_updated({"auth": {"token": "new_token"}}) - assert "auth_token" in updated - assert config.auth_token == "new_token" - - def test_missing_file(self, tmp_path): - yaml_path = tmp_path / "missing.yaml" - config = ChannelConfig(str(yaml_path)) - assert config.raw_data == {} - assert config.auth_token is None - - def test_default_values(self, tmp_path): - yaml_path = tmp_path / "empty.yaml" - yaml_path.write_text("{}") - config = ChannelConfig(str(yaml_path)) - assert config.max_auth_attempts == 5 - assert config.lockout_seconds == 300 - assert config.keyword_blocklist == set() - assert config.hook_mappings == [] - assert config.access_policies == {} - assert config.allow_from == {} - - def test_feishu_ws_config_websocket_mode(self, tmp_path): - yaml_path = tmp_path / "ws_config.yaml" - yaml_path.write_text(""" -feishu: - mode: "websocket" - app_id: "test" - app_secret: "test" -""") - config = ChannelConfig(str(yaml_path)) - ws_config = config.feishu_ws_config - assert ws_config is not None - assert ws_config["mode"] == "websocket" - - def test_dingtalk_ws_config_websocket_mode(self, tmp_path): - yaml_path = tmp_path / "ws_config.yaml" - yaml_path.write_text(""" -dingtalk: - mode: "websocket" - client_id: "test" - client_secret: "test" -""") - config = ChannelConfig(str(yaml_path)) - ws_config = config.dingtalk_ws_config - assert ws_config is not None - assert ws_config["mode"] == "websocket" diff --git a/backend/test/unit/channel/startup/test_channel_container.py b/backend/test/unit/channel/startup/test_channel_container.py deleted file mode 100644 index f932281e..00000000 --- a/backend/test/unit/channel/startup/test_channel_container.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from yuxi.channel.container import ChannelContainer, _InfraBundle, _WorkerBundle -from yuxi.channel.domain.service.pipeline import Pipeline - - -class TestChannelContainer: - @pytest.fixture - def mock_pipeline(self): - pipeline = MagicMock(spec=Pipeline) - pipeline.middlewares = [] - return pipeline - - @pytest.fixture - def mock_container(self, mock_pipeline): - return ChannelContainer( - pipeline=mock_pipeline, - inbound_service=MagicMock(), - dispatch_service=MagicMock(), - delivery_service=MagicMock(), - session_resolver=MagicMock(), - binding_service=MagicMock(), - config_service=MagicMock(), - auth_service=MagicMock(), - channel_config=MagicMock(), - worker_pool=MagicMock(), - outbox_worker=MagicMock(), - session_factory=MagicMock(), - ) - - @pytest.mark.asyncio - async def test_shutdown_with_none_services(self, mock_pipeline): - container = ChannelContainer(pipeline=mock_pipeline) - await container.shutdown() - - @pytest.mark.asyncio - async def test_shutdown_closes_adapters(self, mock_container): - adapter = AsyncMock() - mock_container.adapters = {"web": adapter} - await mock_container.shutdown() - adapter.close.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_stops_worker_pool(self, mock_container): - await mock_container.shutdown() - mock_container.worker_pool.stop.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_stops_outbox_worker(self, mock_container): - await mock_container.shutdown() - mock_container.outbox_worker.stop.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_stops_sse_endpoint(self, mock_container): - sse = AsyncMock() - mock_container.sse_endpoint = sse - await mock_container.shutdown() - sse.stop.assert_awaited_once() - sse.broadcast_shutdown.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_stops_ws_manager(self, mock_container): - ws = AsyncMock() - mock_container.ws_manager = ws - await mock_container.shutdown() - ws.stop_all.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_closes_redis(self, mock_container): - redis = AsyncMock() - mock_container.redis = redis - await mock_container.shutdown() - redis.aclose.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_publishes_gateway_shutdown(self, mock_container): - publisher = AsyncMock() - mock_container.event_publisher = publisher - await mock_container.shutdown() - publisher.publish.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_safe_exception_handling(self, mock_container): - mock_container.worker_pool.stop.side_effect = Exception("pool error") - await mock_container.shutdown() - - @pytest.mark.asyncio - async def test_shutdown_pubsub_subscriber(self, mock_container): - pubsub = AsyncMock() - mock_container.pubsub_subscriber = pubsub - await mock_container.shutdown() - pubsub.stop.assert_awaited_once() - - -class TestInfraBundle: - def test_creation(self): - infra = _InfraBundle( - queue_port=MagicMock(), - event_publisher=MagicMock(), - content_filter=MagicMock(), - config_reload=MagicMock(), - cache_port=MagicMock(), - rate_limit_port=MagicMock(), - bot_loop_guard=MagicMock(), - circuit_breaker=MagicMock(), - metrics=MagicMock(), - signature_verifier=MagicMock(), - ) - assert infra.queue_port is not None - - -class TestWorkerBundle: - def test_creation(self): - workers = _WorkerBundle( - dispatch_service=MagicMock(), - binding_service=MagicMock(), - config_service=MagicMock(), - worker_pool=MagicMock(), - outbox_worker=MagicMock(), - session_factory=MagicMock(), - binding_repo=MagicMock(), - session_repo=MagicMock(), - outbox_repo=MagicMock(), - message_repo=MagicMock(), - message_log_repo=MagicMock(), - ) - assert workers.dispatch_service is not None diff --git a/backend/test/unit/channel/startup/test_channel_registry.py b/backend/test/unit/channel/startup/test_channel_registry.py deleted file mode 100644 index 416d170f..00000000 --- a/backend/test/unit/channel/startup/test_channel_registry.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest - -from yuxi.channel.channels._registry import ChannelRegistry, get_registered_channels - - -class TestChannelRegistry: - @pytest.fixture - def registry(self): - return ChannelRegistry() - - def test_register(self, registry): - adapter_cls = MagicMock() - registry.register("web", adapter_cls) - assert "web" in registry._channels - assert registry._channels["web"] is adapter_cls - - def test_get(self, registry): - adapter_cls = MagicMock() - registry.register("web", adapter_cls) - result = registry.get("web") - assert result is adapter_cls - - def test_get_not_found(self, registry): - result = registry.get("unknown") - assert result is None - - def test_unregister(self, registry): - adapter_cls = MagicMock() - registry.register("web", adapter_cls) - registry.unregister("web") - assert "web" not in registry._channels - - def test_list(self, registry): - adapter_cls1 = MagicMock() - adapter_cls2 = MagicMock() - registry.register("web", adapter_cls1) - registry.register("feishu", adapter_cls2) - channels = registry.list() - assert "web" in channels - assert "feishu" in channels - assert len(channels) == 2 - - def test_clear(self, registry): - adapter_cls = MagicMock() - registry.register("web", adapter_cls) - registry.clear() - assert len(registry._channels) == 0 - - def test_get_registered_channels(self): - channels = get_registered_channels() - assert isinstance(channels, dict) diff --git a/backend/test/unit/channel/startup/test_channel_session.py b/backend/test/unit/channel/startup/test_channel_session.py deleted file mode 100644 index 5fcd317d..00000000 --- a/backend/test/unit/channel/startup/test_channel_session.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.domain.model.session.channel_session import ChannelSession - - -class TestChannelSession: - def test_basic_creation(self): - session = ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="1", - channel_type="web", - channel_session_key="key1", - status="active", - title="Test Session", - ) - assert session.id == 1 - assert session.thread_id == "thread123" - assert session.user_id == "user1" - assert session.agent_id == "1" - assert session.channel_type == "web" - assert session.channel_session_key == "key1" - assert session.status == "active" - assert session.title == "Test Session" - - def test_creation_with_none_title(self): - session = ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="1", - channel_type="web", - channel_session_key="key1", - status="active", - title=None, - ) - assert session.title is None - - def test_to_dict(self): - session = ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="1", - channel_type="web", - channel_session_key="key1", - status="active", - title="Test Session", - ) - d = session.to_dict() - assert d["id"] == 1 - assert d["thread_id"] == "thread123" - assert d["user_id"] == "user1" - assert d["agent_id"] == "1" - assert d["channel_type"] == "web" - assert d["channel_session_key"] == "key1" - assert d["status"] == "active" - assert d["title"] == "Test Session" - - def test_from_dict(self): - d = { - "id": 1, - "thread_id": "thread123", - "user_id": "user1", - "agent_id": "1", - "channel_type": "web", - "channel_session_key": "key1", - "status": "active", - "title": "Test Session", - } - session = ChannelSession.from_dict(d) - assert session.id == 1 - assert session.thread_id == "thread123" - assert session.title == "Test Session" - - def test_default_values(self): - session = ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="1", - channel_type="web", - channel_session_key="key1", - status="active", - ) - assert session.title is None diff --git a/backend/test/unit/channel/startup/test_composite_content_filter.py b/backend/test/unit/channel/startup/test_composite_content_filter.py deleted file mode 100644 index 51191ec5..00000000 --- a/backend/test/unit/channel/startup/test_composite_content_filter.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.infrastructure.content_filter.composite_content_filter import CompositeContentFilter - - -class TestCompositeContentFilter: - @pytest.fixture - def mock_llm_filter(self): - filter_mock = AsyncMock() - filter_mock.check.return_value = MagicMock(passed=True, violations=[], masked_fields=[]) - return filter_mock - - @pytest.fixture - def mock_redis_filter(self): - filter_mock = AsyncMock() - filter_mock.check.return_value = MagicMock(passed=True, violations=[], masked_fields=[]) - return filter_mock - - @pytest.fixture - def composite_filter(self, mock_llm_filter, mock_redis_filter): - return CompositeContentFilter( - llm_filter=mock_llm_filter, - redis_filter=mock_redis_filter, - ) - - @pytest.mark.asyncio - async def test_check_all_pass(self, composite_filter, mock_llm_filter, mock_redis_filter): - result = await composite_filter.check("Hello world") - assert result.passed is True - assert result.violations == [] - mock_llm_filter.check.assert_awaited_once() - mock_redis_filter.check.assert_awaited_once() - - @pytest.mark.asyncio - async def test_check_llm_fails(self, composite_filter, mock_llm_filter): - mock_llm_filter.check.return_value = MagicMock( - passed=False, violations=["inappropriate"], masked_fields=[] - ) - result = await composite_filter.check("Hello world") - assert result.passed is False - assert "inappropriate" in result.violations - - @pytest.mark.asyncio - async def test_check_redis_fails(self, composite_filter, mock_redis_filter): - mock_redis_filter.check.return_value = MagicMock( - passed=False, violations=["blocked"], masked_fields=[] - ) - result = await composite_filter.check("Hello world") - assert result.passed is False - assert "blocked" in result.violations - - @pytest.mark.asyncio - async def test_check_both_fail(self, composite_filter, mock_llm_filter, mock_redis_filter): - mock_llm_filter.check.return_value = MagicMock( - passed=False, violations=["inappropriate"], masked_fields=[] - ) - mock_redis_filter.check.return_value = MagicMock( - passed=False, violations=["blocked"], masked_fields=[] - ) - result = await composite_filter.check("Hello world") - assert result.passed is False - assert "inappropriate" in result.violations - assert "blocked" in result.violations - - @pytest.mark.asyncio - async def test_check_with_empty_text(self, composite_filter): - result = await composite_filter.check("") - assert result.passed is True - - @pytest.mark.asyncio - async def test_check_exception_handling(self, composite_filter, mock_llm_filter): - mock_llm_filter.check.side_effect = Exception("llm error") - result = await composite_filter.check("Hello") - assert result.passed is True diff --git a/backend/test/unit/channel/startup/test_config_service.py b/backend/test/unit/channel/startup/test_config_service.py deleted file mode 100644 index fdbbb371..00000000 --- a/backend/test/unit/channel/startup/test_config_service.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.application.service.config_service import ConfigService -from yuxi.channel.domain.middleware.configurable import Configurable -from yuxi.channel.domain.service.pipeline import Pipeline - - -class TestConfigService: - @pytest.fixture - def mock_config_reload(self): - return AsyncMock() - - @pytest.fixture - def mock_pipeline(self): - pipeline = MagicMock(spec=Pipeline) - pipeline.middlewares = [] - return pipeline - - @pytest.fixture - def mock_channel_config(self): - config = AsyncMock() - config.on_config_updated.return_value = ["auth_token"] - return config - - @pytest.fixture - def config_service(self, mock_config_reload, mock_pipeline, mock_channel_config): - return ConfigService( - config_data={"auth": {"token": "old"}}, - config_reload=mock_config_reload, - pipeline=mock_pipeline, - channel_config=mock_channel_config, - ) - - @pytest.mark.asyncio - async def test_reload(self, config_service, mock_config_reload): - mock_config_reload.reload.return_value = {"auth": {"token": "new"}} - updated, config_hash = await config_service.reload() - assert "auth_token" in updated - assert config_hash is not None - assert len(config_hash) == 8 - - @pytest.mark.asyncio - async def test_reload_no_change(self, config_service, mock_config_reload): - mock_config_reload.reload.return_value = {} - updated, config_hash = await config_service.reload() - assert updated == ["auth_token"] - - @pytest.mark.asyncio - async def test_reload_with_configurable_middleware(self, config_service, mock_pipeline): - mock_mw = MagicMock(spec=Configurable) - mock_mw.on_config_updated.return_value = ["keyword_filter"] - mock_pipeline.middlewares = [mock_mw] - mock_config_reload = AsyncMock() - mock_config_reload.reload.return_value = {"keyword_blocklist": ["bad"]} - - service = ConfigService( - config_data={}, - config_reload=mock_config_reload, - pipeline=mock_pipeline, - ) - updated, _ = await service.reload() - assert "keyword_filter" in updated - - def test_config_property(self, config_service): - assert config_service.config == {"auth": {"token": "old"}} - - def test_update_configurable_middlewares(self, config_service, mock_pipeline): - mock_mw = MagicMock(spec=Configurable) - mock_mw.on_config_updated.return_value = ["test_mw"] - mock_pipeline.middlewares = [mock_mw] - updated = config_service._update_configurable_middlewares() - assert "test_mw" in updated - - def test_update_configurable_middlewares_no_configurable(self, config_service, mock_pipeline): - mock_pipeline.middlewares = [MagicMock()] - updated = config_service._update_configurable_middlewares() - assert updated == [] diff --git a/backend/test/unit/channel/startup/test_container_factory.py b/backend/test/unit/channel/startup/test_container_factory.py deleted file mode 100644 index aa0d181d..00000000 --- a/backend/test/unit/channel/startup/test_container_factory.py +++ /dev/null @@ -1,140 +0,0 @@ -from __future__ import annotations - -import os -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest -import redis.asyncio as aioredis - -from yuxi.channel.container import ChannelContainerFactory -from yuxi.channel.infrastructure.config.channel_config import ChannelConfig - - -class TestChannelContainerFactory: - @pytest.fixture - def mock_redis(self): - redis = AsyncMock(spec=aioredis.Redis) - redis.pubsub.return_value = AsyncMock() - return redis - - @pytest.fixture - def mock_config(self): - config = MagicMock(spec=ChannelConfig) - config.raw_data = {} - config.auth_token = "" - config.auth_password = "" - config.max_auth_attempts = 5 - config.lockout_seconds = 300 - config.get_channel_config.return_value = {} - config.hook_mappings = [] - return config - - def test_build_config(self, tmp_path): - yaml_path = tmp_path / "test_config.yaml" - yaml_path.write_text("auth:\n token: test\n") - config = ChannelContainerFactory._build_config(str(yaml_path)) - assert isinstance(config, ChannelConfig) - - def test_connect_redis(self, mock_redis): - with patch("yuxi.channel.container.aioredis.from_url", return_value=mock_redis): - result = ChannelContainerFactory._connect_redis("redis://localhost") - assert result is mock_redis - - def test_build_infra(self, mock_redis, mock_config): - infra = ChannelContainerFactory._build_infra(mock_redis, mock_config) - assert infra.queue_port is not None - assert infra.event_publisher is not None - assert infra.content_filter is not None - assert infra.config_reload is not None - assert infra.cache_port is not None - assert infra.rate_limit_port is not None - assert infra.bot_loop_guard is not None - assert infra.circuit_breaker is not None - assert infra.metrics is not None - assert infra.signature_verifier is not None - - def test_build_auth_service(self, mock_redis, mock_config): - infra = ChannelContainerFactory._build_infra(mock_redis, mock_config) - auth_service = ChannelContainerFactory._build_auth_service(infra, mock_config) - assert auth_service is not None - - def test_build_pipeline(self, mock_redis, mock_config): - infra = ChannelContainerFactory._build_infra(mock_redis, mock_config) - auth_service = ChannelContainerFactory._build_auth_service(infra, mock_config) - pipeline = ChannelContainerFactory._build_pipeline(infra, mock_config, auth_service) - assert pipeline is not None - assert len(pipeline.middlewares) > 0 - - @pytest.mark.asyncio - async def test_build_adapters(self, mock_redis, mock_config): - with patch("yuxi.channel.container.get_registered_channels") as mock_get: - mock_adapter_cls = MagicMock() - mock_adapter = AsyncMock() - mock_adapter_cls.get_default_config.return_value = {} - mock_adapter_cls.return_value = mock_adapter - mock_get.return_value = {"web": mock_adapter_cls} - - infra = ChannelContainerFactory._build_infra(mock_redis, mock_config) - adapters, sse = await ChannelContainerFactory._build_adapters(mock_config, infra) - assert "web" in adapters - mock_adapter.open.assert_awaited_once() - - def test_apply_env_overrides_feishu(self): - with patch.dict(os.environ, {"FEISHU_APP_ID": "test_id", "FEISHU_APP_SECRET": "test_secret"}): - result = ChannelContainerFactory._apply_env_overrides("feishu", {}) - assert result["app_id"] == "test_id" - assert result["app_secret"] == "test_secret" - - def test_apply_env_overrides_dingtalk(self): - with patch.dict(os.environ, {"DINGTALK_CLIENT_ID": "test_id", "DINGTALK_CLIENT_SECRET": "test_secret"}): - result = ChannelContainerFactory._apply_env_overrides("dingtalk", {}) - assert result["client_id"] == "test_id" - assert result["client_secret"] == "test_secret" - - def test_apply_env_overrides_other(self): - result = ChannelContainerFactory._apply_env_overrides("web", {}) - assert result == {} - - def test_inject_bot_ids(self, mock_redis, mock_config): - infra = ChannelContainerFactory._build_infra(mock_redis, mock_config) - auth_service = ChannelContainerFactory._build_auth_service(infra, mock_config) - pipeline = ChannelContainerFactory._build_pipeline(infra, mock_config, auth_service) - - adapter = MagicMock() - adapter.bot_id = "bot123" - adapters = {"feishu": adapter} - ChannelContainerFactory._inject_bot_ids(pipeline, adapters) - - @pytest.mark.asyncio - async def test_build_ws_connections(self, mock_redis, mock_config): - infra = ChannelContainerFactory._build_infra(mock_redis, mock_config) - auth_service = ChannelContainerFactory._build_auth_service(infra, mock_config) - pipeline = ChannelContainerFactory._build_pipeline(infra, mock_config, auth_service) - - adapter = MagicMock() - adapter.ws_connection = None - adapters = {"web": adapter} - - tracer = MagicMock() - ws_manager = await ChannelContainerFactory._build_ws_connections(adapters, pipeline, tracer, metrics=infra.metrics) - assert ws_manager is not None - - def test_build_workers(self, mock_redis, mock_config): - infra = ChannelContainerFactory._build_infra(mock_redis, mock_config) - auth_service = ChannelContainerFactory._build_auth_service(infra, mock_config) - pipeline = ChannelContainerFactory._build_pipeline(infra, mock_config, auth_service) - - adapter = MagicMock() - adapter.ws_connection = None - adapters = {"web": adapter} - - with patch("yuxi.channel.container.pg_manager") as mock_pg: - workers = ChannelContainerFactory._build_workers( - infra, adapters, None, mock_config, pipeline, mock_redis - ) - assert workers.dispatch_service is not None - assert workers.binding_service is not None - assert workers.config_service is not None - assert workers.worker_pool is not None - assert workers.outbox_worker is not None - assert workers.session_factory is not None diff --git a/backend/test/unit/channel/startup/test_delivery_service.py b/backend/test/unit/channel/startup/test_delivery_service.py deleted file mode 100644 index 18e304ab..00000000 --- a/backend/test/unit/channel/startup/test_delivery_service.py +++ /dev/null @@ -1,146 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.application.service.delivery_service import DeliveryService -from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError -from yuxi.channel.domain.model.message.dispatch_result import DispatchResult, SendResult -from yuxi.channel.domain.model.session.channel_session import ChannelSession - - -class TestDeliveryService: - @pytest.fixture - def mock_adapters(self): - adapter = AsyncMock() - adapter.send_message.return_value = SendResult(success=True) - adapter.capabilities.media = True - return {"web": adapter} - - @pytest.fixture - def mock_message_repo(self): - return AsyncMock() - - @pytest.fixture - def mock_outbox_repo(self): - return AsyncMock() - - @pytest.fixture - def mock_event_publisher(self): - return AsyncMock() - - @pytest.fixture - def mock_agent_port(self): - agent = AsyncMock() - agent.stream_chat.return_value = "Hello, user!" - return agent - - @pytest.fixture - def delivery_service(self, mock_adapters, mock_message_repo, mock_outbox_repo, mock_event_publisher, mock_agent_port): - return DeliveryService( - adapters=mock_adapters, - message_repo=mock_message_repo, - outbox_repo=mock_outbox_repo, - event_publisher=mock_event_publisher, - agent_port=mock_agent_port, - ) - - @pytest.fixture - def sample_session(self): - return ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="1", - channel_type="web", - channel_session_key="key1", - status="active", - title=None, - ) - - @pytest.fixture - def sample_payload(self): - return { - "message_id": "msg123", - "channel_type": "web", - "content": "Hello", - "sender_id": "user1", - "session_id": "session123", - "trace_id": "trace123", - "metadata": {"deliver": True}, - "attachments": [{"url": "http://example.com/image.png", "media_type": "image"}], - } - - @pytest.mark.asyncio - async def test_deliver_success(self, delivery_service, sample_payload, sample_session): - result = await delivery_service.deliver(sample_payload, sample_session) - assert result.success is True - - @pytest.mark.asyncio - async def test_deliver_no_deliver(self, delivery_service, mock_message_repo, sample_payload, sample_session): - sample_payload["metadata"]["deliver"] = False - result = await delivery_service.deliver(sample_payload, sample_session) - assert result.success is True - mock_message_repo.save_assistant_message.assert_awaited_once() - - @pytest.mark.asyncio - async def test_deliver_agent_crash(self, delivery_service, mock_agent_port, sample_payload, sample_session): - mock_agent_port.stream_chat.side_effect = AgentCrashError("agent crashed") - result = await delivery_service.deliver(sample_payload, sample_session) - assert result.success is False - assert result.error == "agent_crash" - - @pytest.mark.asyncio - async def test_deliver_send_failure(self, delivery_service, mock_adapters, mock_outbox_repo, sample_payload, sample_session): - mock_adapters["web"].send_message.return_value = SendResult(success=False, error="send failed") - result = await delivery_service.deliver(sample_payload, sample_session) - mock_outbox_repo.enqueue.assert_awaited_once() - - @pytest.mark.asyncio - async def test_deliver_no_adapter(self, delivery_service, sample_payload, sample_session): - sample_payload["channel_type"] = "unknown" - result = await delivery_service.deliver(sample_payload, sample_session) - assert result.success is False - assert "no adapter" in result.error - - @pytest.mark.asyncio - async def test_deliver_publishes_event(self, delivery_service, mock_event_publisher, sample_payload, sample_session): - await delivery_service.deliver(sample_payload, sample_session) - mock_event_publisher.publish.assert_awaited_once() - - @pytest.mark.asyncio - async def test_deliver_saves_message(self, delivery_service, mock_message_repo, sample_payload, sample_session): - await delivery_service.deliver(sample_payload, sample_session) - mock_message_repo.save_assistant_message.assert_awaited_once() - - @pytest.mark.asyncio - async def test_deliver_with_media(self, delivery_service, mock_adapters, sample_payload, sample_session): - await delivery_service.deliver(sample_payload, sample_session) - mock_adapters["web"].send_media.assert_awaited_once() - - @pytest.mark.asyncio - async def test_deliver_typing_indicator(self, delivery_service, mock_adapters, sample_payload, sample_session): - mock_adapters["web"].capabilities.typing = True - await delivery_service.deliver(sample_payload, sample_session) - - @pytest.mark.asyncio - async def test_call_agent_timeout(self, delivery_service, mock_agent_port, sample_payload, sample_session): - mock_agent_port.stream_chat.side_effect = TimeoutError("timeout") - with pytest.raises(AgentCrashError): - await delivery_service._call_agent(sample_payload, sample_session, "Hello", "web", "msg123", "trace123") - - @pytest.mark.asyncio - async def test_try_send_success(self, delivery_service, mock_adapters, sample_session): - result = await delivery_service._try_send( - mock_adapters["web"], "thread123", "Hello", "web", "trace123", "msg123" - ) - assert result.success is True - - @pytest.mark.asyncio - async def test_try_send_failure(self, delivery_service, mock_adapters, sample_session): - mock_adapters["web"].send_message.side_effect = Exception("send error") - result = await delivery_service._try_send( - mock_adapters["web"], "thread123", "Hello", "web", "trace123", "msg123" - ) - assert result.success is False diff --git a/backend/test/unit/channel/startup/test_dingtalk_adapter.py b/backend/test/unit/channel/startup/test_dingtalk_adapter.py deleted file mode 100644 index 39d12d20..00000000 --- a/backend/test/unit/channel/startup/test_dingtalk_adapter.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.channels.dingtalk.adapter import DingTalkAdapter -from yuxi.channel.domain.model.message.unified_message import UnifiedMessage -from yuxi.channel.domain.model.message.peer import Peer -from yuxi.channel.domain.model.message.dispatch_result import SendResult - - -class TestDingTalkAdapter: - @pytest.fixture - def adapter(self): - return DingTalkAdapter(config={"client_id": "test", "client_secret": "test"}) - - @pytest.mark.asyncio - async def test_open(self, adapter): - await adapter.open() - assert adapter.is_open is True - - @pytest.mark.asyncio - async def test_close(self, adapter): - await adapter.open() - await adapter.close() - assert adapter.is_open is False - - @pytest.mark.asyncio - async def test_send_message(self, adapter): - await adapter.open() - msg = UnifiedMessage( - message_id="msg123", - channel_type="dingtalk", - sender=Peer(id="bot", name="Bot"), - content="Hello", - ) - result = await adapter.send_message("thread123", msg) - assert isinstance(result, SendResult) - - @pytest.mark.asyncio - async def test_send_media(self, adapter): - await adapter.open() - result = await adapter.send_media("thread123", "http://example.com/image.png", "image") - assert isinstance(result, SendResult) - - @pytest.mark.asyncio - async def test_send_typing(self, adapter): - await adapter.open() - result = await adapter.send_typing("thread123") - assert isinstance(result, SendResult) - - def test_capabilities(self, adapter): - assert adapter.capabilities.text is True - assert adapter.capabilities.media is True - assert adapter.capabilities.typing is True - assert adapter.capabilities.group is True - - def test_bot_id(self, adapter): - assert adapter.bot_id is None - - def test_ws_connection(self, adapter): - assert adapter.ws_connection is None - - @pytest.mark.asyncio - async def test_send_message_not_open(self, adapter): - msg = UnifiedMessage( - message_id="msg123", - channel_type="dingtalk", - sender=Peer(id="bot", name="Bot"), - content="Hello", - ) - result = await adapter.send_message("thread123", msg) - assert result.success is False diff --git a/backend/test/unit/channel/startup/test_dispatch_service.py b/backend/test/unit/channel/startup/test_dispatch_service.py deleted file mode 100644 index 37f4a112..00000000 --- a/backend/test/unit/channel/startup/test_dispatch_service.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.application.service.dispatch_service import DispatchService -from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError -from yuxi.channel.domain.exception.recoverable_error import RecoverableError -from yuxi.channel.domain.model.message.dispatch_result import DispatchResult - - -class TestDispatchService: - @pytest.fixture - def mock_content_filter(self): - filter_mock = AsyncMock() - filter_mock.check.return_value = MagicMock(passed=True, violations=[], masked_fields=[]) - return filter_mock - - @pytest.fixture - def mock_bot_loop_guard(self): - guard = AsyncMock() - guard.check.return_value = True - return guard - - @pytest.fixture - def mock_circuit_breaker(self): - cb = AsyncMock() - cb.is_available.return_value = True - return cb - - @pytest.fixture - def mock_session_resolver(self): - resolver = AsyncMock() - session = MagicMock() - session.thread_id = "thread123" - session.agent_id = "1" - resolver.resolve.return_value = session - return resolver - - @pytest.fixture - def mock_delivery_service(self): - delivery = AsyncMock() - delivery.deliver.return_value = DispatchResult(success=True, message_id="msg123") - return delivery - - @pytest.fixture - def dispatch_service(self, mock_content_filter, mock_bot_loop_guard, mock_circuit_breaker, mock_session_resolver, mock_delivery_service): - return DispatchService( - content_filter=mock_content_filter, - bot_loop_guard=mock_bot_loop_guard, - circuit_breaker=mock_circuit_breaker, - session_resolver=mock_session_resolver, - delivery_service=mock_delivery_service, - ) - - @pytest.fixture - def sample_payload(self): - return { - "message_id": "msg123", - "channel_type": "web", - "content": "Hello", - "sender_id": "user1", - "session_id": "session123", - "trace_id": "trace123", - "metadata": {"is_group": False}, - } - - @pytest.mark.asyncio - async def test_dispatch_success(self, dispatch_service, sample_payload): - result = await dispatch_service.dispatch(sample_payload) - assert isinstance(result, DispatchResult) - - @pytest.mark.asyncio - async def test_dispatch_content_blocked(self, dispatch_service, mock_content_filter, sample_payload): - mock_content_filter.check.return_value = MagicMock(passed=False, violations=["badword"], masked_fields=[]) - result = await dispatch_service.dispatch(sample_payload) - assert result.success is True - - @pytest.mark.asyncio - async def test_dispatch_bot_loop_detected(self, dispatch_service, mock_bot_loop_guard, sample_payload): - mock_bot_loop_guard.check.return_value = False - result = await dispatch_service.dispatch(sample_payload) - assert result.success is True - - @pytest.mark.asyncio - async def test_dispatch_session_none(self, dispatch_service, mock_session_resolver, sample_payload): - mock_session_resolver.resolve.return_value = None - result = await dispatch_service.dispatch(sample_payload) - assert result.success is False - assert result.error == "session_error" - - @pytest.mark.asyncio - async def test_dispatch_circuit_open(self, dispatch_service, mock_circuit_breaker, sample_payload): - mock_circuit_breaker.is_available.return_value = False - result = await dispatch_service.dispatch(sample_payload) - assert result.success is False - assert result.error == "circuit_open" - - @pytest.mark.asyncio - async def test_dispatch_delivery_success(self, dispatch_service, mock_delivery_service, sample_payload): - result = await dispatch_service.dispatch(sample_payload) - mock_circuit_breaker.record_success.assert_awaited_once() - - @pytest.mark.asyncio - async def test_dispatch_agent_crash(self, dispatch_service, mock_delivery_service, sample_payload): - mock_delivery_service.deliver.side_effect = AgentCrashError("agent crashed") - with pytest.raises(AgentCrashError): - await dispatch_service.dispatch(sample_payload) - mock_circuit_breaker.record_failure.assert_awaited_once() - - @pytest.mark.asyncio - async def test_dispatch_recoverable_error(self, dispatch_service, mock_delivery_service, sample_payload): - mock_delivery_service.deliver.side_effect = RecoverableError("recoverable") - with pytest.raises(RecoverableError): - await dispatch_service.dispatch(sample_payload) - - @pytest.mark.asyncio - async def test_dispatch_generic_error(self, dispatch_service, mock_delivery_service, sample_payload): - mock_delivery_service.deliver.side_effect = Exception("generic error") - result = await dispatch_service.dispatch(sample_payload) - assert result.success is False - mock_circuit_breaker.record_failure.assert_awaited_once() - - @pytest.mark.asyncio - async def test_dispatch_with_metrics(self, dispatch_service, sample_payload): - metrics = AsyncMock() - dispatch_service._metrics = metrics - await dispatch_service.dispatch(sample_payload) - metrics.record_worker_dispatch_total.assert_awaited() diff --git a/backend/test/unit/channel/startup/test_domain_events.py b/backend/test/unit/channel/startup/test_domain_events.py deleted file mode 100644 index 16309ef8..00000000 --- a/backend/test/unit/channel/startup/test_domain_events.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.domain.event.message_received import MessageReceived -from yuxi.channel.domain.event.message_blocked import MessageBlocked -from yuxi.channel.domain.event.message_replied import MessageReplied -from yuxi.channel.domain.event.agent_error import AgentError -from yuxi.channel.domain.event.gateway_shutdown import GatewayShutdown - - -class TestMessageReceived: - def test_creation(self): - event = MessageReceived( - message_id="msg123", - channel_type="web", - sender_id="user1", - content="Hello", - ) - assert event.message_id == "msg123" - assert event.channel_type == "web" - assert event.sender_id == "user1" - assert event.content == "Hello" - - def test_to_dict(self): - event = MessageReceived( - message_id="msg123", - channel_type="web", - sender_id="user1", - content="Hello", - ) - d = event.to_dict() - assert d["message_id"] == "msg123" - assert d["channel_type"] == "web" - - -class TestMessageBlocked: - def test_creation(self): - event = MessageBlocked( - message_id="msg123", - channel_type="web", - reason="rate_limit", - ) - assert event.message_id == "msg123" - assert event.reason == "rate_limit" - - def test_to_dict(self): - event = MessageBlocked( - message_id="msg123", - channel_type="web", - reason="rate_limit", - ) - d = event.to_dict() - assert d["reason"] == "rate_limit" - - -class TestMessageReplied: - def test_creation(self): - event = MessageReplied( - message_id="msg123", - channel_type="web", - reply_content="Hello back", - ) - assert event.message_id == "msg123" - assert event.reply_content == "Hello back" - - def test_to_dict(self): - event = MessageReplied( - message_id="msg123", - channel_type="web", - reply_content="Hello back", - ) - d = event.to_dict() - assert d["reply_content"] == "Hello back" - - -class TestAgentError: - def test_creation(self): - event = AgentError( - message_id="msg123", - channel_type="web", - error="timeout", - ) - assert event.message_id == "msg123" - assert event.error == "timeout" - - def test_to_dict(self): - event = AgentError( - message_id="msg123", - channel_type="web", - error="timeout", - ) - d = event.to_dict() - assert d["error"] == "timeout" - - -class TestGatewayShutdown: - def test_creation(self): - event = GatewayShutdown(reason="maintenance") - assert event.reason == "maintenance" - - def test_to_dict(self): - event = GatewayShutdown(reason="maintenance") - d = event.to_dict() - assert d["reason"] == "maintenance" diff --git a/backend/test/unit/channel/startup/test_domain_exceptions.py b/backend/test/unit/channel/startup/test_domain_exceptions.py deleted file mode 100644 index d5554ed2..00000000 --- a/backend/test/unit/channel/startup/test_domain_exceptions.py +++ /dev/null @@ -1,68 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError -from yuxi.channel.domain.exception.recoverable_error import RecoverableError -from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError -from yuxi.channel.domain.exception.channel_error import ChannelError -from yuxi.channel.domain.exception.abort_mapping import AbortMapping - - -class TestAgentCrashError: - def test_creation(self): - error = AgentCrashError("agent crashed") - assert str(error) == "agent crashed" - assert error.message == "agent crashed" - - def test_creation_with_details(self): - error = AgentCrashError("agent crashed", details={"agent_id": "1"}) - assert error.details == {"agent_id": "1"} - - -class TestRecoverableError: - def test_creation(self): - error = RecoverableError("temporary failure") - assert str(error) == "temporary failure" - assert error.message == "temporary failure" - - def test_creation_with_retry_after(self): - error = RecoverableError("temporary failure", retry_after=5) - assert error.retry_after == 5 - - -class TestUnrecoverableError: - def test_creation(self): - error = UnrecoverableError("permanent failure") - assert str(error) == "permanent failure" - assert error.message == "permanent failure" - - -class TestChannelError: - def test_creation(self): - error = ChannelError("channel error") - assert str(error) == "channel error" - assert error.message == "channel error" - - def test_creation_with_code(self): - error = ChannelError("channel error", code="CHANNEL_ERROR") - assert error.code == "CHANNEL_ERROR" - - -class TestAbortMapping: - def test_creation(self): - mapping = AbortMapping( - code="AUTH_FAILED", - reason="Authentication failed", - status_code=401, - ) - assert mapping.code == "AUTH_FAILED" - assert mapping.reason == "Authentication failed" - assert mapping.status_code == 401 - - def test_default_status_code(self): - mapping = AbortMapping( - code="TEST", - reason="Test", - ) - assert mapping.status_code == 400 diff --git a/backend/test/unit/channel/startup/test_feishu_adapter.py b/backend/test/unit/channel/startup/test_feishu_adapter.py deleted file mode 100644 index a585ad10..00000000 --- a/backend/test/unit/channel/startup/test_feishu_adapter.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.channels.feishu.adapter import FeishuAdapter -from yuxi.channel.domain.model.message.unified_message import UnifiedMessage -from yuxi.channel.domain.model.message.peer import Peer -from yuxi.channel.domain.model.message.dispatch_result import SendResult - - -class TestFeishuAdapter: - @pytest.fixture - def adapter(self): - return FeishuAdapter(config={"app_id": "test", "app_secret": "test"}) - - @pytest.mark.asyncio - async def test_open(self, adapter): - await adapter.open() - assert adapter.is_open is True - - @pytest.mark.asyncio - async def test_close(self, adapter): - await adapter.open() - await adapter.close() - assert adapter.is_open is False - - @pytest.mark.asyncio - async def test_send_message(self, adapter): - await adapter.open() - msg = UnifiedMessage( - message_id="msg123", - channel_type="feishu", - sender=Peer(id="bot", name="Bot"), - content="Hello", - ) - result = await adapter.send_message("thread123", msg) - assert isinstance(result, SendResult) - - @pytest.mark.asyncio - async def test_send_media(self, adapter): - await adapter.open() - result = await adapter.send_media("thread123", "http://example.com/image.png", "image") - assert isinstance(result, SendResult) - - @pytest.mark.asyncio - async def test_send_typing(self, adapter): - await adapter.open() - result = await adapter.send_typing("thread123") - assert isinstance(result, SendResult) - - def test_capabilities(self, adapter): - assert adapter.capabilities.text is True - assert adapter.capabilities.media is True - assert adapter.capabilities.typing is True - assert adapter.capabilities.group is True - - def test_bot_id(self, adapter): - assert adapter.bot_id is None - - def test_ws_connection(self, adapter): - assert adapter.ws_connection is None - - @pytest.mark.asyncio - async def test_send_message_not_open(self, adapter): - msg = UnifiedMessage( - message_id="msg123", - channel_type="feishu", - sender=Peer(id="bot", name="Bot"), - content="Hello", - ) - result = await adapter.send_message("thread123", msg) - assert result.success is False diff --git a/backend/test/unit/channel/startup/test_hmac_signature_verifier.py b/backend/test/unit/channel/startup/test_hmac_signature_verifier.py deleted file mode 100644 index f56d2868..00000000 --- a/backend/test/unit/channel/startup/test_hmac_signature_verifier.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import annotations - -import hmac - -import pytest - -from yuxi.channel.infrastructure.security.hmac_signature_verifier import HMACSignatureVerifier - - -class TestHMACSignatureVerifier: - @pytest.fixture - def verifier(self): - return HMACSignatureVerifier(secret="test_secret") - - def test_verify_valid_signature(self, verifier): - payload = b'{"test": "data"}' - signature = hmac.new(b"test_secret", payload, "sha256").hexdigest() - result = verifier.verify(payload, signature) - assert result is True - - def test_verify_invalid_signature(self, verifier): - payload = b'{"test": "data"}' - result = verifier.verify(payload, "invalid_signature") - assert result is False - - def test_verify_empty_signature(self, verifier): - payload = b'{"test": "data"}' - result = verifier.verify(payload, "") - assert result is False - - def test_verify_different_secret(self, verifier): - payload = b'{"test": "data"}' - signature = hmac.new(b"different_secret", payload, "sha256").hexdigest() - result = verifier.verify(payload, signature) - assert result is False - - def test_verify_empty_payload(self, verifier): - signature = hmac.new(b"test_secret", b"", "sha256").hexdigest() - result = verifier.verify(b"", signature) - assert result is True diff --git a/backend/test/unit/channel/startup/test_hooks_adapter.py b/backend/test/unit/channel/startup/test_hooks_adapter.py deleted file mode 100644 index aedd0f47..00000000 --- a/backend/test/unit/channel/startup/test_hooks_adapter.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.channels.hooks.adapter import HooksAdapter -from yuxi.channel.domain.model.message.unified_message import UnifiedMessage -from yuxi.channel.domain.model.message.peer import Peer -from yuxi.channel.domain.model.message.dispatch_result import SendResult - - -class TestHooksAdapter: - @pytest.fixture - def adapter(self): - return HooksAdapter(config={"mappings": []}) - - @pytest.mark.asyncio - async def test_open(self, adapter): - await adapter.open() - assert adapter.is_open is True - - @pytest.mark.asyncio - async def test_close(self, adapter): - await adapter.open() - await adapter.close() - assert adapter.is_open is False - - @pytest.mark.asyncio - async def test_send_message(self, adapter): - await adapter.open() - msg = UnifiedMessage( - message_id="msg123", - channel_type="hooks", - sender=Peer(id="bot", name="Bot"), - content="Hello", - ) - result = await adapter.send_message("thread123", msg) - assert isinstance(result, SendResult) - - @pytest.mark.asyncio - async def test_send_media(self, adapter): - await adapter.open() - result = await adapter.send_media("thread123", "http://example.com/image.png", "image") - assert isinstance(result, SendResult) - - @pytest.mark.asyncio - async def test_send_typing(self, adapter): - await adapter.open() - result = await adapter.send_typing("thread123") - assert isinstance(result, SendResult) - - def test_capabilities(self, adapter): - assert adapter.capabilities.text is True - assert adapter.capabilities.media is True - assert adapter.capabilities.typing is False - assert adapter.capabilities.group is False - - def test_bot_id(self, adapter): - assert adapter.bot_id is None - - def test_ws_connection(self, adapter): - assert adapter.ws_connection is None - - @pytest.mark.asyncio - async def test_send_message_not_open(self, adapter): - msg = UnifiedMessage( - message_id="msg123", - channel_type="hooks", - sender=Peer(id="bot", name="Bot"), - content="Hello", - ) - result = await adapter.send_message("thread123", msg) - assert result.success is False diff --git a/backend/test/unit/channel/startup/test_inbound_service.py b/backend/test/unit/channel/startup/test_inbound_service.py deleted file mode 100644 index 3c7df5ab..00000000 --- a/backend/test/unit/channel/startup/test_inbound_service.py +++ /dev/null @@ -1,113 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.application.service.inbound_service import InboundService -from yuxi.channel.domain.model.message.unified_message import UnifiedMessage -from yuxi.channel.domain.model.message.peer import Peer -from yuxi.channel.domain.service.message_context import MessageContext -from yuxi.channel.domain.service.pipeline import Pipeline - - -class TestInboundService: - @pytest.fixture - def mock_pipeline(self): - pipeline = MagicMock(spec=Pipeline) - pipeline.execute = AsyncMock(return_value=MagicMock(spec=MessageContext)) - return pipeline - - @pytest.fixture - def mock_event_publisher(self): - return AsyncMock() - - @pytest.fixture - def mock_message_log_repo(self): - return AsyncMock() - - @pytest.fixture - def sample_message(self): - return UnifiedMessage( - message_id="msg123", - channel_type="web", - sender=Peer(id="user1", name="User"), - content="Hello", - ) - - @pytest.mark.asyncio - async def test_submit_basic(self, mock_pipeline, sample_message): - service = InboundService(mock_pipeline) - result = await service.submit(sample_message, channel_type="web") - mock_pipeline.execute.assert_awaited_once() - assert result is not None - - @pytest.mark.asyncio - async def test_submit_with_trace_id(self, mock_pipeline, sample_message): - service = InboundService(mock_pipeline) - result = await service.submit(sample_message, channel_type="web", trace_id="trace123") - ctx = mock_pipeline.execute.call_args[0][0] - assert ctx.trace_id == "trace123" - - @pytest.mark.asyncio - async def test_submit_creates_log(self, mock_pipeline, sample_message, mock_message_log_repo): - service = InboundService(mock_pipeline, message_log_repo=mock_message_log_repo) - await service.submit(sample_message, channel_type="web") - mock_message_log_repo.create_log.assert_awaited_once() - - @pytest.mark.asyncio - async def test_submit_publishes_event(self, mock_pipeline, sample_message, mock_event_publisher): - service = InboundService(mock_pipeline, event_publisher=mock_event_publisher) - await service.submit(sample_message, channel_type="web") - mock_event_publisher.publish.assert_not_awaited() - - @pytest.mark.asyncio - async def test_submit_aborted_message(self, mock_pipeline, sample_message, mock_event_publisher): - aborted_ctx = MagicMock(spec=MessageContext) - aborted_ctx.is_aborted = True - aborted_ctx.is_skipped = False - aborted_ctx.message = sample_message - aborted_ctx.trace_id = "trace123" - aborted_ctx.abort_reason = "test abort" - aborted_ctx.abort_code = "TEST_ABORT" - mock_pipeline.execute = AsyncMock(return_value=aborted_ctx) - - service = InboundService(mock_pipeline, event_publisher=mock_event_publisher) - await service.submit(sample_message, channel_type="web") - mock_event_publisher.publish.assert_awaited_once() - - @pytest.mark.asyncio - async def test_submit_skipped_message(self, mock_pipeline, sample_message, mock_event_publisher): - skipped_ctx = MagicMock(spec=MessageContext) - skipped_ctx.is_aborted = False - skipped_ctx.is_skipped = True - skipped_ctx.message = sample_message - skipped_ctx.trace_id = "trace123" - mock_pipeline.execute = AsyncMock(return_value=skipped_ctx) - - service = InboundService(mock_pipeline, event_publisher=mock_event_publisher) - await service.submit(sample_message, channel_type="web") - mock_event_publisher.publish.assert_not_awaited() - - @pytest.mark.asyncio - async def test_submit_log_exception_handling(self, mock_pipeline, sample_message, mock_message_log_repo): - mock_message_log_repo.create_log.side_effect = Exception("log error") - service = InboundService(mock_pipeline, message_log_repo=mock_message_log_repo) - result = await service.submit(sample_message, channel_type="web") - assert result is not None - - @pytest.mark.asyncio - async def test_submit_event_exception_handling(self, mock_pipeline, sample_message, mock_event_publisher): - mock_event_publisher.publish.side_effect = Exception("publish error") - aborted_ctx = MagicMock(spec=MessageContext) - aborted_ctx.is_aborted = True - aborted_ctx.is_skipped = False - aborted_ctx.message = sample_message - aborted_ctx.trace_id = "trace123" - aborted_ctx.abort_reason = "test" - aborted_ctx.abort_code = "TEST" - mock_pipeline.execute = AsyncMock(return_value=aborted_ctx) - - service = InboundService(mock_pipeline, event_publisher=mock_event_publisher) - result = await service.submit(sample_message, channel_type="web") - assert result is not None diff --git a/backend/test/unit/channel/startup/test_llm_content_filter.py b/backend/test/unit/channel/startup/test_llm_content_filter.py deleted file mode 100644 index 5db4affb..00000000 --- a/backend/test/unit/channel/startup/test_llm_content_filter.py +++ /dev/null @@ -1,44 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.infrastructure.content_filter.llm_content_filter import LLMContentFilter - - -class TestLLMContentFilter: - @pytest.fixture - def mock_llm_client(self): - return AsyncMock() - - @pytest.fixture - def filter(self, mock_llm_client): - return LLMContentFilter(llm_client=mock_llm_client) - - @pytest.mark.asyncio - async def test_check_pass(self, filter, mock_llm_client): - mock_llm_client.check.return_value = MagicMock(passed=True, violations=[], masked_fields=[]) - result = await filter.check("Hello world") - assert result.passed is True - assert result.violations == [] - - @pytest.mark.asyncio - async def test_check_fail(self, filter, mock_llm_client): - mock_llm_client.check.return_value = MagicMock( - passed=False, violations=["inappropriate"], masked_fields=[] - ) - result = await filter.check("bad content") - assert result.passed is False - assert "inappropriate" in result.violations - - @pytest.mark.asyncio - async def test_check_exception(self, filter, mock_llm_client): - mock_llm_client.check.side_effect = Exception("llm error") - result = await filter.check("Hello") - assert result.passed is True - - @pytest.mark.asyncio - async def test_check_empty_text(self, filter): - result = await filter.check("") - assert result.passed is True diff --git a/backend/test/unit/channel/startup/test_message_context.py b/backend/test/unit/channel/startup/test_message_context.py deleted file mode 100644 index 5d8f9b98..00000000 --- a/backend/test/unit/channel/startup/test_message_context.py +++ /dev/null @@ -1,67 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.domain.service.message_context import MessageContext -from yuxi.channel.domain.model.message.unified_message import UnifiedMessage -from yuxi.channel.domain.model.message.peer import Peer - - -class TestMessageContext: - @pytest.fixture - def sample_message(self): - return UnifiedMessage( - message_id="msg123", - channel_type="web", - sender=Peer(id="user1", name="User"), - content="Hello", - ) - - def test_basic_creation(self, sample_message): - ctx = MessageContext(message=sample_message) - assert ctx.message == sample_message - assert ctx.is_aborted is False - assert ctx.is_skipped is False - assert ctx.trace_id is None - - def test_abort(self, sample_message): - ctx = MessageContext(message=sample_message) - ctx.abort("TEST", "test abort") - assert ctx.is_aborted is True - assert ctx.abort_code == "TEST" - assert ctx.abort_reason == "test abort" - - def test_skip(self, sample_message): - ctx = MessageContext(message=sample_message) - ctx.skip("test skip") - assert ctx.is_skipped is True - assert ctx.skip_reason == "test skip" - - def test_set_trace_id(self, sample_message): - ctx = MessageContext(message=sample_message) - ctx.set_trace_id("trace123") - assert ctx.trace_id == "trace123" - - def test_set_headers(self, sample_message): - ctx = MessageContext(message=sample_message) - ctx.set_headers({"Authorization": "Bearer token"}) - assert ctx.headers == {"Authorization": "Bearer token"} - - def test_set_metadata(self, sample_message): - ctx = MessageContext(message=sample_message) - ctx.set_metadata({"key": "value"}) - assert ctx.metadata == {"key": "value"} - - def test_multiple_aborts(self, sample_message): - ctx = MessageContext(message=sample_message) - ctx.abort("FIRST", "first abort") - ctx.abort("SECOND", "second abort") - assert ctx.abort_code == "FIRST" - assert ctx.abort_reason == "first abort" - - def test_abort_then_skip(self, sample_message): - ctx = MessageContext(message=sample_message) - ctx.abort("TEST", "test abort") - ctx.skip("test skip") - assert ctx.is_aborted is True - assert ctx.is_skipped is True diff --git a/backend/test/unit/channel/startup/test_middlewares.py b/backend/test/unit/channel/startup/test_middlewares.py deleted file mode 100644 index 53df2033..00000000 --- a/backend/test/unit/channel/startup/test_middlewares.py +++ /dev/null @@ -1,367 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.application.pipeline.middlewares.auth_middleware import AuthMiddleware -from yuxi.channel.application.pipeline.middlewares.validation_middleware import ValidationMiddleware -from yuxi.channel.application.pipeline.middlewares.keyword_filter_middleware import KeywordFilterMiddleware -from yuxi.channel.application.pipeline.middlewares.dedup_middleware import DedupMiddleware -from yuxi.channel.application.pipeline.middlewares.rate_limit_middleware import RateLimitMiddleware -from yuxi.channel.application.pipeline.middlewares.access_policy_middleware import AccessPolicyMiddleware -from yuxi.channel.application.pipeline.middlewares.mention_gate_middleware import MentionGateMiddleware -from yuxi.channel.application.pipeline.middlewares.enqueue_mq_middleware import EnqueueMQMiddleware -from yuxi.channel.domain.service.message_context import MessageContext - - -class TestAuthMiddleware: - @pytest.fixture - def mock_auth_service(self): - service = AsyncMock() - service.authenticate.return_value = (True, "") - return service - - @pytest.fixture - def auth_middleware(self, mock_auth_service): - return AuthMiddleware(auth_service=mock_auth_service) - - @pytest.mark.asyncio - async def test_auth_pass(self, auth_middleware, mock_auth_service): - ctx = MagicMock(spec=MessageContext) - ctx.headers = {"Authorization": "Bearer token"} - result = await auth_middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_auth_fail(self, auth_middleware, mock_auth_service): - mock_auth_service.authenticate.return_value = (False, "auth failed") - ctx = MagicMock(spec=MessageContext) - ctx.headers = {"Authorization": "Bearer wrong"} - result = await auth_middleware.process(ctx) - assert result.is_aborted is True - assert result.abort_code == "AUTH_FAILED" - - @pytest.mark.asyncio - async def test_no_auth_header(self, auth_middleware): - ctx = MagicMock(spec=MessageContext) - ctx.headers = {} - result = await auth_middleware.process(ctx) - assert result.is_aborted is False - - -class TestValidationMiddleware: - @pytest.fixture - def validation_middleware(self): - return ValidationMiddleware() - - @pytest.mark.asyncio - async def test_valid_message(self, validation_middleware): - ctx = MagicMock(spec=MessageContext) - ctx.message.message_id = "msg123" - ctx.message.channel_type = "web" - ctx.message.content = "Hello" - result = await validation_middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_missing_message_id(self, validation_middleware): - ctx = MagicMock(spec=MessageContext) - ctx.message.message_id = "" - ctx.message.channel_type = "web" - ctx.message.content = "Hello" - result = await validation_middleware.process(ctx) - assert result.is_aborted is True - assert result.abort_code == "VALIDATION_ERROR" - - @pytest.mark.asyncio - async def test_missing_channel_type(self, validation_middleware): - ctx = MagicMock(spec=MessageContext) - ctx.message.message_id = "msg123" - ctx.message.channel_type = "" - ctx.message.content = "Hello" - result = await validation_middleware.process(ctx) - assert result.is_aborted is True - - @pytest.mark.asyncio - async def test_missing_content(self, validation_middleware): - ctx = MagicMock(spec=MessageContext) - ctx.message.message_id = "msg123" - ctx.message.channel_type = "web" - ctx.message.content = "" - result = await validation_middleware.process(ctx) - assert result.is_aborted is True - - -class TestKeywordFilterMiddleware: - @pytest.fixture - def mock_matcher(self): - matcher = MagicMock() - matcher.match.return_value = False - return matcher - - @pytest.fixture - def keyword_middleware(self, mock_matcher): - return KeywordFilterMiddleware(keyword_matcher=mock_matcher) - - @pytest.mark.asyncio - async def test_no_match(self, keyword_middleware, mock_matcher): - ctx = MagicMock(spec=MessageContext) - ctx.message.content = "Hello world" - result = await keyword_middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_match_found(self, keyword_middleware, mock_matcher): - mock_matcher.match.return_value = True - ctx = MagicMock(spec=MessageContext) - ctx.message.content = "bad word" - result = await keyword_middleware.process(ctx) - assert result.is_aborted is True - assert result.abort_code == "KEYWORD_BLOCKED" - - @pytest.mark.asyncio - async def test_no_matcher(self): - middleware = KeywordFilterMiddleware() - ctx = MagicMock(spec=MessageContext) - ctx.message.content = "Hello" - result = await middleware.process(ctx) - assert result.is_aborted is False - - -class TestDedupMiddleware: - @pytest.fixture - def mock_cache(self): - cache = AsyncMock() - cache.exists.return_value = False - return cache - - @pytest.fixture - def dedup_middleware(self, mock_cache): - return DedupMiddleware(cache_port=mock_cache) - - @pytest.mark.asyncio - async def test_new_message(self, dedup_middleware, mock_cache): - ctx = MagicMock(spec=MessageContext) - ctx.message.message_id = "msg123" - result = await dedup_middleware.process(ctx) - assert result.is_aborted is False - mock_cache.set.assert_awaited_once() - - @pytest.mark.asyncio - async def test_duplicate_message(self, dedup_middleware, mock_cache): - mock_cache.exists.return_value = True - ctx = MagicMock(spec=MessageContext) - ctx.message.message_id = "msg123" - result = await dedup_middleware.process(ctx) - assert result.is_aborted is True - assert result.abort_code == "DEDUP" - - @pytest.mark.asyncio - async def test_no_cache(self): - middleware = DedupMiddleware() - ctx = MagicMock(spec=MessageContext) - ctx.message.message_id = "msg123" - result = await middleware.process(ctx) - assert result.is_aborted is False - - -class TestRateLimitMiddleware: - @pytest.fixture - def mock_rate_limiter(self): - limiter = AsyncMock() - limiter.is_locked.return_value = (False, 0) - limiter.check_and_incr.return_value = True - return limiter - - @pytest.fixture - def rate_limit_middleware(self, mock_rate_limiter): - return RateLimitMiddleware(rate_limit_port=mock_rate_limiter) - - @pytest.mark.asyncio - async def test_within_limit(self, rate_limit_middleware, mock_rate_limiter): - ctx = MagicMock(spec=MessageContext) - ctx.message.sender.id = "user1" - result = await rate_limit_middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_locked(self, rate_limit_middleware, mock_rate_limiter): - mock_rate_limiter.is_locked.return_value = (True, 120) - ctx = MagicMock(spec=MessageContext) - ctx.message.sender.id = "user1" - result = await rate_limit_middleware.process(ctx) - assert result.is_aborted is True - assert result.abort_code == "RATE_LIMITED" - - @pytest.mark.asyncio - async def test_exceeded(self, rate_limit_middleware, mock_rate_limiter): - mock_rate_limiter.check_and_incr.return_value = False - ctx = MagicMock(spec=MessageContext) - ctx.message.sender.id = "user1" - result = await rate_limit_middleware.process(ctx) - assert result.is_aborted is True - - @pytest.mark.asyncio - async def test_no_limiter(self): - middleware = RateLimitMiddleware() - ctx = MagicMock(spec=MessageContext) - ctx.message.sender.id = "user1" - result = await middleware.process(ctx) - assert result.is_aborted is False - - -class TestAccessPolicyMiddleware: - @pytest.fixture - def access_policy_middleware(self): - return AccessPolicyMiddleware( - access_policies={"web": {"dm_policy": "open"}}, - allow_from={"web": ["user1"]}, - ) - - @pytest.mark.asyncio - async def test_allow_open_policy(self, access_policy_middleware): - ctx = MagicMock(spec=MessageContext) - ctx.message.channel_type = "web" - ctx.message.sender.id = "user1" - ctx.message.metadata.is_group = False - result = await access_policy_middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_allowlist_allowed(self): - middleware = AccessPolicyMiddleware( - access_policies={"web": {"dm_policy": "allowlist"}}, - allow_from={"web": ["user1"]}, - ) - ctx = MagicMock(spec=MessageContext) - ctx.message.channel_type = "web" - ctx.message.sender.id = "user1" - ctx.message.metadata.is_group = False - result = await middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_allowlist_denied(self): - middleware = AccessPolicyMiddleware( - access_policies={"web": {"dm_policy": "allowlist"}}, - allow_from={"web": ["user1"]}, - ) - ctx = MagicMock(spec=MessageContext) - ctx.message.channel_type = "web" - ctx.message.sender.id = "user2" - ctx.message.metadata.is_group = False - result = await middleware.process(ctx) - assert result.is_aborted is True - assert result.abort_code == "ACCESS_DENIED" - - @pytest.mark.asyncio - async def test_no_policies(self): - middleware = AccessPolicyMiddleware() - ctx = MagicMock(spec=MessageContext) - ctx.message.channel_type = "web" - ctx.message.sender.id = "user1" - ctx.message.metadata.is_group = False - result = await middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_group_message_allowed(self, access_policy_middleware): - ctx = MagicMock(spec=MessageContext) - ctx.message.channel_type = "web" - ctx.message.sender.id = "user1" - ctx.message.metadata.is_group = True - result = await access_policy_middleware.process(ctx) - assert result.is_aborted is False - - -class TestMentionGateMiddleware: - @pytest.fixture - def mention_gate_middleware(self): - return MentionGateMiddleware( - default_require_mention=False, - mention_patterns=["@bot"], - ) - - @pytest.mark.asyncio - async def test_no_mention_required(self, mention_gate_middleware): - ctx = MagicMock(spec=MessageContext) - ctx.message.content = "Hello" - ctx.message.metadata.is_group = False - result = await mention_gate_middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_mention_required_and_found(self): - middleware = MentionGateMiddleware( - default_require_mention=True, - mention_patterns=["@bot"], - ) - ctx = MagicMock(spec=MessageContext) - ctx.message.content = "Hello @bot" - ctx.message.metadata.is_group = True - result = await middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_mention_required_not_found(self): - middleware = MentionGateMiddleware( - default_require_mention=True, - mention_patterns=["@bot"], - ) - ctx = MagicMock(spec=MessageContext) - ctx.message.content = "Hello" - ctx.message.metadata.is_group = True - result = await middleware.process(ctx) - assert result.is_aborted is True - assert result.abort_code == "MENTION_REQUIRED" - - @pytest.mark.asyncio - async def test_dm_not_required(self): - middleware = MentionGateMiddleware( - default_require_mention=True, - mention_patterns=["@bot"], - ) - ctx = MagicMock(spec=MessageContext) - ctx.message.content = "Hello" - ctx.message.metadata.is_group = False - result = await middleware.process(ctx) - assert result.is_aborted is False - - -class TestEnqueueMQMiddleware: - @pytest.fixture - def mock_queue(self): - return AsyncMock() - - @pytest.fixture - def enqueue_middleware(self, mock_queue): - return EnqueueMQMiddleware(queue_port=mock_queue) - - @pytest.mark.asyncio - async def test_enqueue_success(self, enqueue_middleware, mock_queue): - ctx = MagicMock(spec=MessageContext) - ctx.message.to_dict.return_value = {"message_id": "msg123"} - ctx.trace_id = "trace123" - result = await enqueue_middleware.process(ctx) - assert result.is_aborted is False - mock_queue.enqueue.assert_awaited_once() - - @pytest.mark.asyncio - async def test_enqueue_no_queue(self): - middleware = EnqueueMQMiddleware() - ctx = MagicMock(spec=MessageContext) - ctx.message.to_dict.return_value = {"message_id": "msg123"} - ctx.trace_id = "trace123" - result = await middleware.process(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_enqueue_exception(self, enqueue_middleware, mock_queue): - mock_queue.enqueue.side_effect = Exception("queue error") - ctx = MagicMock(spec=MessageContext) - ctx.message.to_dict.return_value = {"message_id": "msg123"} - ctx.trace_id = "trace123" - result = await enqueue_middleware.process(ctx) - assert result.is_aborted is True - assert result.abort_code == "ENQUEUE_ERROR" diff --git a/backend/test/unit/channel/startup/test_outbox_retry.py b/backend/test/unit/channel/startup/test_outbox_retry.py deleted file mode 100644 index 087f260c..00000000 --- a/backend/test/unit/channel/startup/test_outbox_retry.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.worker.outbox_retry import OutboxRetryWorker - - -class TestOutboxRetryWorker: - @pytest.fixture - def mock_outbox_repo(self): - return AsyncMock() - - @pytest.fixture - def mock_adapters(self): - adapter = AsyncMock() - adapter.send_message.return_value = MagicMock(success=True) - return {"web": adapter} - - @pytest.fixture - def worker(self, mock_outbox_repo, mock_adapters): - return OutboxRetryWorker( - outbox_repo=mock_outbox_repo, - adapters=mock_adapters, - poll_interval=1.0, - ) - - @pytest.mark.asyncio - async def test_start(self, worker): - await worker.start() - assert worker.is_running is True - - @pytest.mark.asyncio - async def test_stop(self, worker): - await worker.start() - await worker.stop() - assert worker.is_running is False - - @pytest.mark.asyncio - async def test_is_running_property(self, worker): - assert worker.is_running is False - await worker.start() - assert worker.is_running is True - - @pytest.mark.asyncio - async def test_run_loop(self, worker, mock_outbox_repo): - entry = MagicMock() - entry.id = 1 - entry.channel_type = "web" - entry.payload = {"content": "Hello"} - entry.retry_count = 0 - mock_outbox_repo.dequeue_pending.return_value = [entry] - mock_outbox_repo.mark_delivered.return_value = True - - await worker.start() - await worker.stop() - - @pytest.mark.asyncio - async def test_retry_success(self, worker, mock_outbox_repo, mock_adapters): - entry = MagicMock() - entry.id = 1 - entry.channel_type = "web" - entry.payload = {"content": "Hello"} - entry.retry_count = 0 - mock_outbox_repo.dequeue_pending.return_value = [entry] - mock_outbox_repo.mark_delivered.return_value = True - - await worker.start() - await worker.stop() - mock_adapters["web"].send_message.assert_awaited_once() - mock_outbox_repo.mark_delivered.assert_awaited_once_with(1) - - @pytest.mark.asyncio - async def test_retry_failure(self, worker, mock_outbox_repo, mock_adapters): - entry = MagicMock() - entry.id = 1 - entry.channel_type = "web" - entry.payload = {"content": "Hello"} - entry.retry_count = 0 - mock_outbox_repo.dequeue_pending.return_value = [entry] - mock_adapters["web"].send_message.return_value = MagicMock(success=False) - mock_outbox_repo.mark_failed.return_value = True - - await worker.start() - await worker.stop() - mock_outbox_repo.mark_failed.assert_awaited_once_with(1, "retry") - - @pytest.mark.asyncio - async def test_retry_no_adapter(self, worker, mock_outbox_repo): - entry = MagicMock() - entry.id = 1 - entry.channel_type = "unknown" - entry.payload = {"content": "Hello"} - entry.retry_count = 0 - mock_outbox_repo.dequeue_pending.return_value = [entry] - mock_outbox_repo.mark_failed.return_value = True - - await worker.start() - await worker.stop() - mock_outbox_repo.mark_failed.assert_awaited_once_with(1, "no adapter") - - @pytest.mark.asyncio - async def test_retry_exception(self, worker, mock_outbox_repo, mock_adapters): - entry = MagicMock() - entry.id = 1 - entry.channel_type = "web" - entry.payload = {"content": "Hello"} - entry.retry_count = 0 - mock_outbox_repo.dequeue_pending.return_value = [entry] - mock_adapters["web"].send_message.side_effect = Exception("send error") - mock_outbox_repo.mark_failed.return_value = True - - await worker.start() - await worker.stop() - mock_outbox_repo.mark_failed.assert_awaited_once_with(1, "send error") diff --git a/backend/test/unit/channel/startup/test_pipeline.py b/backend/test/unit/channel/startup/test_pipeline.py deleted file mode 100644 index b7c8a6df..00000000 --- a/backend/test/unit/channel/startup/test_pipeline.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.domain.service.pipeline import Pipeline -from yuxi.channel.domain.service.message_context import MessageContext - - -class TestPipeline: - @pytest.fixture - def sample_message(self): - from yuxi.channel.domain.model.message.unified_message import UnifiedMessage - from yuxi.channel.domain.model.message.peer import Peer - return UnifiedMessage( - message_id="msg123", - channel_type="web", - sender=Peer(id="user1", name="User"), - content="Hello", - ) - - @pytest.mark.asyncio - async def test_empty_pipeline(self, sample_message): - pipeline = Pipeline() - ctx = MessageContext(message=sample_message) - result = await pipeline.execute(ctx) - assert result.is_aborted is False - - @pytest.mark.asyncio - async def test_single_middleware(self, sample_message): - middleware = AsyncMock() - middleware.process.return_value = MessageContext(message=sample_message) - pipeline = Pipeline(middlewares=[middleware]) - ctx = MessageContext(message=sample_message) - result = await pipeline.execute(ctx) - middleware.process.assert_awaited_once() - - @pytest.mark.asyncio - async def test_middleware_chain(self, sample_message): - middleware1 = AsyncMock() - middleware1.process.return_value = MessageContext(message=sample_message) - middleware2 = AsyncMock() - middleware2.process.return_value = MessageContext(message=sample_message) - pipeline = Pipeline(middlewares=[middleware1, middleware2]) - ctx = MessageContext(message=sample_message) - result = await pipeline.execute(ctx) - middleware1.process.assert_awaited_once() - middleware2.process.assert_awaited_once() - - @pytest.mark.asyncio - async def test_abort_stops_chain(self, sample_message): - middleware1 = AsyncMock() - aborted_ctx = MessageContext(message=sample_message) - aborted_ctx.abort("TEST", "test abort") - middleware1.process.return_value = aborted_ctx - middleware2 = AsyncMock() - pipeline = Pipeline(middlewares=[middleware1, middleware2]) - ctx = MessageContext(message=sample_message) - result = await pipeline.execute(ctx) - middleware1.process.assert_awaited_once() - middleware2.process.assert_not_awaited() - - @pytest.mark.asyncio - async def test_skip_stops_chain(self, sample_message): - middleware1 = AsyncMock() - skipped_ctx = MessageContext(message=sample_message) - skipped_ctx.skip("test skip") - middleware1.process.return_value = skipped_ctx - middleware2 = AsyncMock() - pipeline = Pipeline(middlewares=[middleware1, middleware2]) - ctx = MessageContext(message=sample_message) - result = await pipeline.execute(ctx) - middleware1.process.assert_awaited_once() - middleware2.process.assert_not_awaited() - - def test_add_middleware(self, sample_message): - pipeline = Pipeline() - middleware = MagicMock() - pipeline.add(middleware) - assert len(pipeline.middlewares) == 1 - - def test_remove_middleware(self, sample_message): - middleware = MagicMock() - pipeline = Pipeline(middlewares=[middleware]) - pipeline.remove(middleware) - assert len(pipeline.middlewares) == 0 diff --git a/backend/test/unit/channel/startup/test_pipeline_builder.py b/backend/test/unit/channel/startup/test_pipeline_builder.py deleted file mode 100644 index d58dc3e1..00000000 --- a/backend/test/unit/channel/startup/test_pipeline_builder.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest - -from yuxi.channel.application.pipeline.builder import build_inbound_pipeline -from yuxi.channel.application.service.auth_service import AuthService -from yuxi.channel.domain.service.pipeline import Pipeline - - -class TestBuildInboundPipeline: - @pytest.fixture - def mock_auth_service(self): - return MagicMock(spec=AuthService) - - @pytest.fixture - def mock_cache(self): - return MagicMock() - - @pytest.fixture - def mock_rate_limiter(self): - return MagicMock() - - @pytest.fixture - def mock_queue(self): - return MagicMock() - - def test_build_basic_pipeline(self, mock_auth_service, mock_cache, mock_rate_limiter, mock_queue): - pipeline = build_inbound_pipeline( - auth_service=mock_auth_service, - cache_port=mock_cache, - rate_limit_port=mock_rate_limiter, - queue_port=mock_queue, - ) - assert isinstance(pipeline, Pipeline) - assert len(pipeline.middlewares) == 8 - - def test_build_pipeline_with_config(self, mock_auth_service, mock_cache, mock_rate_limiter, mock_queue): - config = { - "keyword_blocklist": ["bad"], - "allow_from": {"web": ["user1"]}, - "access_policies": {"web": {"dm_policy": "open"}}, - } - pipeline = build_inbound_pipeline( - auth_service=mock_auth_service, - cache_port=mock_cache, - rate_limit_port=mock_rate_limiter, - queue_port=mock_queue, - channel_config=config, - ) - assert isinstance(pipeline, Pipeline) - assert len(pipeline.middlewares) == 8 - - def test_build_pipeline_with_signature_verifier(self, mock_auth_service, mock_cache, mock_rate_limiter, mock_queue): - verifier = MagicMock() - pipeline = build_inbound_pipeline( - auth_service=mock_auth_service, - cache_port=mock_cache, - rate_limit_port=mock_rate_limiter, - queue_port=mock_queue, - signature_verifier=verifier, - ) - assert isinstance(pipeline, Pipeline) - - def test_build_pipeline_with_metrics(self, mock_auth_service, mock_cache, mock_rate_limiter, mock_queue): - metrics = MagicMock() - pipeline = build_inbound_pipeline( - auth_service=mock_auth_service, - cache_port=mock_cache, - rate_limit_port=mock_rate_limiter, - queue_port=mock_queue, - metrics=metrics, - ) - assert isinstance(pipeline, Pipeline) - - def test_build_pipeline_with_bot_id(self, mock_auth_service, mock_cache, mock_rate_limiter, mock_queue): - pipeline = build_inbound_pipeline( - auth_service=mock_auth_service, - cache_port=mock_cache, - rate_limit_port=mock_rate_limiter, - queue_port=mock_queue, - bot_id="bot123", - ) - assert isinstance(pipeline, Pipeline) - - def test_build_pipeline_with_keyword_matcher(self, mock_auth_service, mock_cache, mock_rate_limiter, mock_queue): - matcher = MagicMock() - pipeline = build_inbound_pipeline( - auth_service=mock_auth_service, - cache_port=mock_cache, - rate_limit_port=mock_rate_limiter, - queue_port=mock_queue, - keyword_matcher=matcher, - ) - assert isinstance(pipeline, Pipeline) - - def test_pipeline_middleware_order(self, mock_auth_service, mock_cache, mock_rate_limiter, mock_queue): - pipeline = build_inbound_pipeline( - auth_service=mock_auth_service, - cache_port=mock_cache, - rate_limit_port=mock_rate_limiter, - queue_port=mock_queue, - ) - names = [mw.name for mw in pipeline.middlewares] - expected = [ - "auth", - "validation", - "keyword_filter", - "dedup", - "rate_limit", - "access_policy", - "mention_gate", - "enqueue_mq", - ] - assert names == expected diff --git a/backend/test/unit/channel/startup/test_prometheus_metrics.py b/backend/test/unit/channel/startup/test_prometheus_metrics.py deleted file mode 100644 index cb20889b..00000000 --- a/backend/test/unit/channel/startup/test_prometheus_metrics.py +++ /dev/null @@ -1,737 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest - -from yuxi.channel.infrastructure.metrics.prometheus_metrics import PrometheusMetrics - - -class TestPrometheusMetrics: - @pytest.fixture - def metrics(self): - return PrometheusMetrics() - - @pytest.mark.asyncio - async def test_record_inbound_received(self, metrics): - await metrics.record_inbound_received("web") - - @pytest.mark.asyncio - async def test_record_inbound_blocked(self, metrics): - await metrics.record_inbound_blocked("web", "rate_limit") - - @pytest.mark.asyncio - async def test_record_outbound_sent(self, metrics): - await metrics.record_outbound_sent("web") - - @pytest.mark.asyncio - async def test_record_outbox_enqueued(self, metrics): - await metrics.record_outbox_enqueued() - - @pytest.mark.asyncio - async def test_record_outbox_delivered(self, metrics): - await metrics.record_outbox_delivered() - - @pytest.mark.asyncio - async def test_record_outbox_failed(self, metrics): - await metrics.record_outbox_failed() - - @pytest.mark.asyncio - async def test_record_worker_dispatch_total(self, metrics): - await metrics.record_worker_dispatch_total() - - @pytest.mark.asyncio - async def test_record_worker_dispatch_success(self, metrics): - await metrics.record_worker_dispatch_success() - - @pytest.mark.asyncio - async def test_record_worker_dispatch_failure(self, metrics): - await metrics.record_worker_dispatch_failure() - - @pytest.mark.asyncio - async def test_record_ws_connection(self, metrics): - await metrics.record_ws_connection("feishu") - - @pytest.mark.asyncio - async def test_record_ws_disconnection(self, metrics): - await metrics.record_ws_disconnection("feishu") - - @pytest.mark.asyncio - async def test_record_ws_message(self, metrics): - await metrics.record_ws_message("feishu") - - @pytest.mark.asyncio - async def test_record_ws_error(self, metrics): - await metrics.record_ws_error("feishu") - - @pytest.mark.asyncio - async def test_record_sse_connection(self, metrics): - await metrics.record_sse_connection() - - @pytest.mark.asyncio - async def test_record_sse_disconnection(self, metrics): - await metrics.record_sse_disconnection() - - @pytest.mark.asyncio - async def test_record_sse_message(self, metrics): - await metrics.record_sse_message() - - @pytest.mark.asyncio - async def test_record_sse_error(self, metrics): - await metrics.record_sse_error() - - @pytest.mark.asyncio - async def test_record_auth_success(self, metrics): - await metrics.record_auth_success() - - @pytest.mark.asyncio - async def test_record_auth_failure(self, metrics): - await metrics.record_auth_failure() - - @pytest.mark.asyncio - async def test_record_session_created(self, metrics): - await metrics.record_session_created() - - @pytest.mark.asyncio - async def test_record_session_resolved(self, metrics): - await metrics.record_session_resolved() - - @pytest.mark.asyncio - async def test_record_message_saved(self, metrics): - await metrics.record_message_saved() - - @pytest.mark.asyncio - async def test_record_message_log_created(self, metrics): - await metrics.record_message_log_created() - - @pytest.mark.asyncio - async def test_record_binding_created(self, metrics): - await metrics.record_binding_created() - - @pytest.mark.asyncio - async def test_record_binding_deleted(self, metrics): - await metrics.record_binding_deleted() - - @pytest.mark.asyncio - async def test_record_config_reload(self, metrics): - await metrics.record_config_reload() - - @pytest.mark.asyncio - async def test_record_config_reload_error(self, metrics): - await metrics.record_config_reload_error() - - @pytest.mark.asyncio - async def test_record_content_filter_blocked(self, metrics): - await metrics.record_content_filter_blocked() - - @pytest.mark.asyncio - async def test_record_content_filter_passed(self, metrics): - await metrics.record_content_filter_passed() - - @pytest.mark.asyncio - async def test_record_rate_limit_hit(self, metrics): - await metrics.record_rate_limit_hit() - - @pytest.mark.asyncio - async def test_record_circuit_breaker_open(self, metrics): - await metrics.record_circuit_breaker_open() - - @pytest.mark.asyncio - async def test_record_circuit_breaker_close(self, metrics): - await metrics.record_circuit_breaker_close() - - @pytest.mark.asyncio - async def test_record_bot_loop_detected(self, metrics): - await metrics.record_bot_loop_detected() - - @pytest.mark.asyncio - async def test_record_signature_verify_success(self, metrics): - await metrics.record_signature_verify_success() - - @pytest.mark.asyncio - async def test_record_signature_verify_failure(self, metrics): - await metrics.record_signature_verify_failure() - - @pytest.mark.asyncio - async def test_record_keyword_match(self, metrics): - await metrics.record_keyword_match() - - @pytest.mark.asyncio - async def test_record_keyword_no_match(self, metrics): - await metrics.record_keyword_no_match() - - @pytest.mark.asyncio - async def test_record_mention_gate_passed(self, metrics): - await metrics.record_mention_gate_passed() - - @pytest.mark.asyncio - async def test_record_mention_gate_blocked(self, metrics): - await metrics.record_mention_gate_blocked() - - @pytest.mark.asyncio - async def test_record_access_policy_allowed(self, metrics): - await metrics.record_access_policy_allowed() - - @pytest.mark.asyncio - async def test_record_access_policy_denied(self, metrics): - await metrics.record_access_policy_denied() - - @pytest.mark.asyncio - async def test_record_dedup_hit(self, metrics): - await metrics.record_dedup_hit() - - @pytest.mark.asyncio - async def test_record_dedup_miss(self, metrics): - await metrics.record_dedup_miss() - - @pytest.mark.asyncio - async def test_record_adapter_open(self, metrics): - await metrics.record_adapter_open("web") - - @pytest.mark.asyncio - async def test_record_adapter_close(self, metrics): - await metrics.record_adapter_close("web") - - @pytest.mark.asyncio - async def test_record_adapter_error(self, metrics): - await metrics.record_adapter_error("web") - - @pytest.mark.asyncio - async def test_record_queue_enqueue(self, metrics): - await metrics.record_queue_enqueue() - - @pytest.mark.asyncio - async def test_record_queue_dequeue(self, metrics): - await metrics.record_queue_dequeue() - - @pytest.mark.asyncio - async def test_record_queue_ack(self, metrics): - await metrics.record_queue_ack() - - @pytest.mark.asyncio - async def test_record_cache_hit(self, metrics): - await metrics.record_cache_hit() - - @pytest.mark.asyncio - async def test_record_cache_miss(self, metrics): - await metrics.record_cache_miss() - - @pytest.mark.asyncio - async def test_record_pubsub_publish(self, metrics): - await metrics.record_pubsub_publish() - - @pytest.mark.asyncio - async def test_record_pubsub_receive(self, metrics): - await metrics.record_pubsub_receive() - - @pytest.mark.asyncio - async def test_record_agent_request(self, metrics): - await metrics.record_agent_request() - - @pytest.mark.asyncio - async def test_record_agent_response(self, metrics): - await metrics.record_agent_response() - - @pytest.mark.asyncio - async def test_record_agent_error(self, metrics): - await metrics.record_agent_error() - - @pytest.mark.asyncio - async def test_record_agent_timeout(self, metrics): - await metrics.record_agent_timeout() - - @pytest.mark.asyncio - async def test_record_agent_stream_chunk(self, metrics): - await metrics.record_agent_stream_chunk() - - @pytest.mark.asyncio - async def test_record_agent_stream_complete(self, metrics): - await metrics.record_agent_stream_complete() - - @pytest.mark.asyncio - async def test_record_agent_stream_error(self, metrics): - await metrics.record_agent_stream_error() - - @pytest.mark.asyncio - async def test_record_http_request(self, metrics): - await metrics.record_http_request("GET", "/test") - - @pytest.mark.asyncio - async def test_record_http_response(self, metrics): - await metrics.record_http_response("GET", "/test", 200) - - @pytest.mark.asyncio - async def test_record_http_error(self, metrics): - await metrics.record_http_error("GET", "/test", 500) - - @pytest.mark.asyncio - async def test_record_http_duration(self, metrics): - await metrics.record_http_duration("GET", "/test", 0.5) - - @pytest.mark.asyncio - async def test_record_db_query(self, metrics): - await metrics.record_db_query("SELECT") - - @pytest.mark.asyncio - async def test_record_db_error(self, metrics): - await metrics.record_db_error("SELECT") - - @pytest.mark.asyncio - async def test_record_db_duration(self, metrics): - await metrics.record_db_duration("SELECT", 0.1) - - @pytest.mark.asyncio - async def test_record_redis_command(self, metrics): - await metrics.record_redis_command("GET") - - @pytest.mark.asyncio - async def test_record_redis_error(self, metrics): - await metrics.record_redis_error("GET") - - @pytest.mark.asyncio - async def test_record_redis_duration(self, metrics): - await metrics.record_redis_duration("GET", 0.01) - - @pytest.mark.asyncio - async def test_record_startup_phase(self, metrics): - await metrics.record_startup_phase("config", 0.1) - - @pytest.mark.asyncio - async def test_record_shutdown_phase(self, metrics): - await metrics.record_shutdown_phase("cleanup", 0.1) - - @pytest.mark.asyncio - async def test_record_goroutine_count(self, metrics): - await metrics.record_goroutine_count(10) - - @pytest.mark.asyncio - async def test_record_memory_usage(self, metrics): - await metrics.record_memory_usage(100) - - @pytest.mark.asyncio - async def test_record_cpu_usage(self, metrics): - await metrics.record_cpu_usage(50.0) - - @pytest.mark.asyncio - async def test_record_gc_duration(self, metrics): - await metrics.record_gc_duration(0.01) - - @pytest.mark.asyncio - async def test_record_gc_runs(self, metrics): - await metrics.record_gc_runs(1) - - @pytest.mark.asyncio - async def test_record_heap_alloc(self, metrics): - await metrics.record_heap_alloc(1000) - - @pytest.mark.asyncio - async def test_record_heap_sys(self, metrics): - await metrics.record_heap_sys(2000) - - @pytest.mark.asyncio - async def test_record_heap_idle(self, metrics): - await metrics.record_heap_idle(500) - - @pytest.mark.asyncio - async def test_record_heap_inuse(self, metrics): - await metrics.record_heap_inuse(1500) - - @pytest.mark.asyncio - async def test_record_stack_inuse(self, metrics): - await metrics.record_stack_inuse(100) - - @pytest.mark.asyncio - async def test_record_stack_sys(self, metrics): - await metrics.record_stack_sys(200) - - @pytest.mark.asyncio - async def test_record_mspan_inuse(self, metrics): - await metrics.record_mspan_inuse(10) - - @pytest.mark.asyncio - async def test_record_mspan_sys(self, metrics): - await metrics.record_mspan_sys(20) - - @pytest.mark.asyncio - async def test_record_mcache_inuse(self, metrics): - await metrics.record_mcache_inuse(5) - - @pytest.mark.asyncio - async def test_record_mcache_sys(self, metrics): - await metrics.record_mcache_sys(10) - - @pytest.mark.asyncio - async def test_record_buck_hash_sys(self, metrics): - await metrics.record_buck_hash_sys(1) - - @pytest.mark.asyncio - async def test_record_gcsys(self, metrics): - await metrics.record_gcsys(2) - - @pytest.mark.asyncio - async def test_record_other_sys(self, metrics): - await metrics.record_other_sys(3) - - @pytest.mark.asyncio - async def test_record_next_gc(self, metrics): - await metrics.record_next_gc(5000) - - @pytest.mark.asyncio - async def test_record_last_gc(self, metrics): - await metrics.record_last_gc(1000) - - @pytest.mark.asyncio - async def test_record_pause_total_ns(self, metrics): - await metrics.record_pause_total_ns(100) - - @pytest.mark.asyncio - async def test_record_num_gc(self, metrics): - await metrics.record_num_gc(5) - - @pytest.mark.asyncio - async def test_record_enable_gc(self, metrics): - await metrics.record_enable_gc(True) - - @pytest.mark.asyncio - async def test_record_debug_gc(self, metrics): - await metrics.record_debug_gc(False) - - @pytest.mark.asyncio - async def test_record_num_forced_gc(self, metrics): - await metrics.record_num_forced_gc(0) - - @pytest.mark.asyncio - async def test_record_heap_objects(self, metrics): - await metrics.record_heap_objects(100) - - @pytest.mark.asyncio - async def test_record_total_alloc(self, metrics): - await metrics.record_total_alloc(10000) - - @pytest.mark.asyncio - async def test_record_sys(self, metrics): - await metrics.record_sys(5000) - - @pytest.mark.asyncio - async def test_record_lookups(self, metrics): - await metrics.record_lookups(10) - - @pytest.mark.asyncio - async def test_record_mallocs(self, metrics): - await metrics.record_mallocs(1000) - - @pytest.mark.asyncio - async def test_record_frees(self, metrics): - await metrics.record_frees(500) - - @pytest.mark.asyncio - async def test_record_heap_released(self, metrics): - await metrics.record_heap_released(200) - - @pytest.mark.asyncio - async def test_record_heap_fragments(self, metrics): - await metrics.record_heap_fragments(5) - - @pytest.mark.asyncio - async def test_record_live_objects(self, metrics): - await metrics.record_live_objects(500) - - @pytest.mark.asyncio - async def test_record_gc_cpu_fraction(self, metrics): - await metrics.record_gc_cpu_fraction(0.1) - - @pytest.mark.asyncio - async def test_record_gc_scan_duration(self, metrics): - await metrics.record_gc_scan_duration(0.01) - - @pytest.mark.asyncio - async def test_record_gc_mark_duration(self, metrics): - await metrics.record_gc_mark_duration(0.02) - - @pytest.mark.asyncio - async def test_record_gc_sweep_duration(self, metrics): - await metrics.record_gc_sweep_duration(0.005) - - @pytest.mark.asyncio - async def test_record_gc_pause_duration(self, metrics): - await metrics.record_gc_pause_duration(0.001) - - @pytest.mark.asyncio - async def test_record_gc_heap_live(self, metrics): - await metrics.record_gc_heap_live(1000) - - @pytest.mark.asyncio - async def test_record_gc_heap_dead(self, metrics): - await metrics.record_gc_heap_dead(500) - - @pytest.mark.asyncio - async def test_record_gc_heap_reachable(self, metrics): - await metrics.record_gc_heap_reachable(1500) - - @pytest.mark.asyncio - async def test_record_gc_heap_unreachable(self, metrics): - await metrics.record_gc_heap_unreachable(200) - - @pytest.mark.asyncio - async def test_record_gc_heap_fragmentation(self, metrics): - await metrics.record_gc_heap_fragmentation(0.1) - - @pytest.mark.asyncio - async def test_record_gc_heap_utilization(self, metrics): - await metrics.record_gc_heap_utilization(0.8) - - @pytest.mark.asyncio - async def test_record_gc_heap_growth(self, metrics): - await metrics.record_gc_heap_growth(100) - - @pytest.mark.asyncio - async def test_record_gc_heap_shrink(self, metrics): - await metrics.record_gc_heap_shrink(50) - - @pytest.mark.asyncio - async def test_record_gc_heap_target(self, metrics): - await metrics.record_gc_heap_target(2000) - - @pytest.mark.asyncio - async def test_record_gc_heap_trigger(self, metrics): - await metrics.record_gc_heap_trigger(1500) - - @pytest.mark.asyncio - async def test_record_gc_heap_live_objects(self, metrics): - await metrics.record_gc_heap_live_objects(100) - - @pytest.mark.asyncio - async def test_record_gc_heap_dead_objects(self, metrics): - await metrics.record_gc_heap_dead_objects(50) - - @pytest.mark.asyncio - async def test_record_gc_heap_reachable_objects(self, metrics): - await metrics.record_gc_heap_reachable_objects(150) - - @pytest.mark.asyncio - async def test_record_gc_heap_unreachable_objects(self, metrics): - await metrics.record_gc_heap_unreachable_objects(20) - - @pytest.mark.asyncio - async def test_record_gc_heap_fragmentation_objects(self, metrics): - await metrics.record_gc_heap_fragmentation_objects(5) - - @pytest.mark.asyncio - async def test_record_gc_heap_utilization_objects(self, metrics): - await metrics.record_gc_heap_utilization_objects(0.75) - - @pytest.mark.asyncio - async def test_record_gc_heap_growth_objects(self, metrics): - await metrics.record_gc_heap_growth_objects(10) - - @pytest.mark.asyncio - async def test_record_gc_heap_shrink_objects(self, metrics): - await metrics.record_gc_heap_shrink_objects(5) - - @pytest.mark.asyncio - async def test_record_gc_heap_target_objects(self, metrics): - await metrics.record_gc_heap_target_objects(200) - - @pytest.mark.asyncio - async def test_record_gc_heap_trigger_objects(self, metrics): - await metrics.record_gc_heap_trigger_objects(150) - - @pytest.mark.asyncio - async def test_record_gc_cpu_fraction_objects(self, metrics): - await metrics.record_gc_cpu_fraction_objects(0.05) - - @pytest.mark.asyncio - async def test_record_gc_scan_duration_objects(self, metrics): - await metrics.record_gc_scan_duration_objects(0.005) - - @pytest.mark.asyncio - async def test_record_gc_mark_duration_objects(self, metrics): - await metrics.record_gc_mark_duration_objects(0.01) - - @pytest.mark.asyncio - async def test_record_gc_sweep_duration_objects(self, metrics): - await metrics.record_gc_sweep_duration_objects(0.002) - - @pytest.mark.asyncio - async def test_record_gc_pause_duration_objects(self, metrics): - await metrics.record_gc_pause_duration_objects(0.0005) - - @pytest.mark.asyncio - async def test_record_gc_heap_live_bytes(self, metrics): - await metrics.record_gc_heap_live_bytes(1000) - - @pytest.mark.asyncio - async def test_record_gc_heap_dead_bytes(self, metrics): - await metrics.record_gc_heap_dead_bytes(500) - - @pytest.mark.asyncio - async def test_record_gc_heap_reachable_bytes(self, metrics): - await metrics.record_gc_heap_reachable_bytes(1500) - - @pytest.mark.asyncio - async def test_record_gc_heap_unreachable_bytes(self, metrics): - await metrics.record_gc_heap_unreachable_bytes(200) - - @pytest.mark.asyncio - async def test_record_gc_heap_fragmentation_bytes(self, metrics): - await metrics.record_gc_heap_fragmentation_bytes(50) - - @pytest.mark.asyncio - async def test_record_gc_heap_utilization_bytes(self, metrics): - await metrics.record_gc_heap_utilization_bytes(0.8) - - @pytest.mark.asyncio - async def test_record_gc_heap_growth_bytes(self, metrics): - await metrics.record_gc_heap_growth_bytes(100) - - @pytest.mark.asyncio - async def test_record_gc_heap_shrink_bytes(self, metrics): - await metrics.record_gc_heap_shrink_bytes(50) - - @pytest.mark.asyncio - async def test_record_gc_heap_target_bytes(self, metrics): - await metrics.record_gc_heap_target_bytes(2000) - - @pytest.mark.asyncio - async def test_record_gc_heap_trigger_bytes(self, metrics): - await metrics.record_gc_heap_trigger_bytes(1500) - - @pytest.mark.asyncio - async def test_record_gc_cpu_fraction_bytes(self, metrics): - await metrics.record_gc_cpu_fraction_bytes(0.1) - - @pytest.mark.asyncio - async def test_record_gc_scan_duration_bytes(self, metrics): - await metrics.record_gc_scan_duration_bytes(0.01) - - @pytest.mark.asyncio - async def test_record_gc_mark_duration_bytes(self, metrics): - await metrics.record_gc_mark_duration_bytes(0.02) - - @pytest.mark.asyncio - async def test_record_gc_sweep_duration_bytes(self, metrics): - await metrics.record_gc_sweep_duration_bytes(0.005) - - @pytest.mark.asyncio - async def test_record_gc_pause_duration_bytes(self, metrics): - await metrics.record_gc_pause_duration_bytes(0.001) - - @pytest.mark.asyncio - async def test_record_gc_heap_live_count(self, metrics): - await metrics.record_gc_heap_live_count(100) - - @pytest.mark.asyncio - async def test_record_gc_heap_dead_count(self, metrics): - await metrics.record_gc_heap_dead_count(50) - - @pytest.mark.asyncio - async def test_record_gc_heap_reachable_count(self, metrics): - await metrics.record_gc_heap_reachable_count(150) - - @pytest.mark.asyncio - async def test_record_gc_heap_unreachable_count(self, metrics): - await metrics.record_gc_heap_unreachable_count(20) - - @pytest.mark.asyncio - async def test_record_gc_heap_fragmentation_count(self, metrics): - await metrics.record_gc_heap_fragmentation_count(5) - - @pytest.mark.asyncio - async def test_record_gc_heap_utilization_count(self, metrics): - await metrics.record_gc_heap_utilization_count(0.75) - - @pytest.mark.asyncio - async def test_record_gc_heap_growth_count(self, metrics): - await metrics.record_gc_heap_growth_count(10) - - @pytest.mark.asyncio - async def test_record_gc_heap_shrink_count(self, metrics): - await metrics.record_gc_heap_shrink_count(5) - - @pytest.mark.asyncio - async def test_record_gc_heap_target_count(self, metrics): - await metrics.record_gc_heap_target_count(200) - - @pytest.mark.asyncio - async def test_record_gc_heap_trigger_count(self, metrics): - await metrics.record_gc_heap_trigger_count(150) - - @pytest.mark.asyncio - async def test_record_gc_cpu_fraction_count(self, metrics): - await metrics.record_gc_cpu_fraction_count(0.05) - - @pytest.mark.asyncio - async def test_record_gc_scan_duration_count(self, metrics): - await metrics.record_gc_scan_duration_count(0.005) - - @pytest.mark.asyncio - async def test_record_gc_mark_duration_count(self, metrics): - await metrics.record_gc_mark_duration_count(0.01) - - @pytest.mark.asyncio - async def test_record_gc_sweep_duration_count(self, metrics): - await metrics.record_gc_sweep_duration_count(0.002) - - @pytest.mark.asyncio - async def test_record_gc_pause_duration_count(self, metrics): - await metrics.record_gc_pause_duration_count(0.0005) - - @pytest.mark.asyncio - async def test_record_gc_heap_live_size(self, metrics): - await metrics.record_gc_heap_live_size(1000) - - @pytest.mark.asyncio - async def test_record_gc_heap_dead_size(self, metrics): - await metrics.record_gc_heap_dead_size(500) - - @pytest.mark.asyncio - async def test_record_gc_heap_reachable_size(self, metrics): - await metrics.record_gc_heap_reachable_size(1500) - - @pytest.mark.asyncio - async def test_record_gc_heap_unreachable_size(self, metrics): - await metrics.record_gc_heap_unreachable_size(200) - - @pytest.mark.asyncio - async def test_record_gc_heap_fragmentation_size(self, metrics): - await metrics.record_gc_heap_fragmentation_size(50) - - @pytest.mark.asyncio - async def test_record_gc_heap_utilization_size(self, metrics): - await metrics.record_gc_heap_utilization_size(0.8) - - @pytest.mark.asyncio - async def test_record_gc_heap_growth_size(self, metrics): - await metrics.record_gc_heap_growth_size(100) - - @pytest.mark.asyncio - async def test_record_gc_heap_shrink_size(self, metrics): - await metrics.record_gc_heap_shrink_size(50) - - @pytest.mark.asyncio - async def test_record_gc_heap_target_size(self, metrics): - await metrics.record_gc_heap_target_size(2000) - - @pytest.mark.asyncio - async def test_record_gc_heap_trigger_size(self, metrics): - await metrics.record_gc_heap_trigger_size(1500) - - @pytest.mark.asyncio - async def test_record_gc_cpu_fraction_size(self, metrics): - await metrics.record_gc_cpu_fraction_size(0.1) - - @pytest.mark.asyncio - async def test_record_gc_scan_duration_size(self, metrics): - await metrics.record_gc_scan_duration_size(0.01) - - @pytest.mark.asyncio - async def test_record_gc_mark_duration_size(self, metrics): - await metrics.record_gc_mark_duration_size(0.02) - - @pytest.mark.asyncio - async def test_record_gc_sweep_duration_size(self, metrics): - await metrics.record_gc_sweep_duration_size(0.005) - - @pytest.mark.asyncio - async def test_record_gc_pause_duration_size(self, metrics): - await metrics.record_gc_pause_duration_size(0.001) diff --git a/backend/test/unit/channel/startup/test_redis_bot_loop_guard.py b/backend/test/unit/channel/startup/test_redis_bot_loop_guard.py deleted file mode 100644 index 24e705d0..00000000 --- a/backend/test/unit/channel/startup/test_redis_bot_loop_guard.py +++ /dev/null @@ -1,46 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock - -import pytest - -from yuxi.channel.infrastructure.cache.redis_bot_loop_guard import RedisBotLoopGuard - - -class TestRedisBotLoopGuard: - @pytest.fixture - def mock_redis(self): - return AsyncMock() - - @pytest.fixture - def guard(self, mock_redis): - return RedisBotLoopGuard( - redis=mock_redis, - max_bot_messages=3, - window_seconds=60, - ) - - @pytest.mark.asyncio - async def test_check_within_limit(self, guard, mock_redis): - mock_redis.incr.return_value = 2 - result = await guard.check("session1") - assert result is True - - @pytest.mark.asyncio - async def test_check_exceeds_limit(self, guard, mock_redis): - mock_redis.incr.return_value = 4 - result = await guard.check("session1") - assert result is False - - @pytest.mark.asyncio - async def test_check_first_message(self, guard, mock_redis): - mock_redis.incr.return_value = 1 - mock_redis.expire.return_value = True - result = await guard.check("session1") - assert result is True - mock_redis.expire.assert_awaited_once() - - @pytest.mark.asyncio - async def test_reset(self, guard, mock_redis): - await guard.reset("session1") - mock_redis.delete.assert_awaited_once() diff --git a/backend/test/unit/channel/startup/test_redis_cache.py b/backend/test/unit/channel/startup/test_redis_cache.py deleted file mode 100644 index 812ee8f4..00000000 --- a/backend/test/unit/channel/startup/test_redis_cache.py +++ /dev/null @@ -1,72 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock - -import pytest - -from yuxi.channel.infrastructure.cache.redis_cache import RedisCache - - -class TestRedisCache: - @pytest.fixture - def mock_redis(self): - return AsyncMock() - - @pytest.fixture - def cache(self, mock_redis): - return RedisCache(redis=mock_redis) - - @pytest.mark.asyncio - async def test_get(self, cache, mock_redis): - mock_redis.get.return_value = b'{"data": "test"}' - result = await cache.get("key1") - assert result == {"data": "test"} - mock_redis.get.assert_awaited_once_with("key1") - - @pytest.mark.asyncio - async def test_get_none(self, cache, mock_redis): - mock_redis.get.return_value = None - result = await cache.get("key1") - assert result is None - - @pytest.mark.asyncio - async def test_set(self, cache, mock_redis): - await cache.set("key1", {"data": "test"}, ttl=60) - mock_redis.setex.assert_awaited_once() - - @pytest.mark.asyncio - async def test_delete(self, cache, mock_redis): - await cache.delete("key1") - mock_redis.delete.assert_awaited_once_with("key1") - - @pytest.mark.asyncio - async def test_exists(self, cache, mock_redis): - mock_redis.exists.return_value = 1 - result = await cache.exists("key1") - assert result is True - - @pytest.mark.asyncio - async def test_exists_false(self, cache, mock_redis): - mock_redis.exists.return_value = 0 - result = await cache.exists("key1") - assert result is False - - @pytest.mark.asyncio - async def test_incr(self, cache, mock_redis): - mock_redis.incr.return_value = 5 - result = await cache.incr("counter") - assert result == 5 - mock_redis.incr.assert_awaited_once_with("counter") - - @pytest.mark.asyncio - async def test_expire(self, cache, mock_redis): - mock_redis.expire.return_value = True - result = await cache.expire("key1", 60) - assert result is True - mock_redis.expire.assert_awaited_once_with("key1", 60) - - @pytest.mark.asyncio - async def test_get_json_decode_error(self, cache, mock_redis): - mock_redis.get.return_value = b"invalid json" - result = await cache.get("key1") - assert result is None diff --git a/backend/test/unit/channel/startup/test_redis_circuit_breaker.py b/backend/test/unit/channel/startup/test_redis_circuit_breaker.py deleted file mode 100644 index a5f70784..00000000 --- a/backend/test/unit/channel/startup/test_redis_circuit_breaker.py +++ /dev/null @@ -1,64 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock - -import pytest - -from yuxi.channel.infrastructure.cache.redis_circuit_breaker import RedisCircuitBreaker - - -class TestRedisCircuitBreaker: - @pytest.fixture - def mock_redis(self): - return AsyncMock() - - @pytest.fixture - def cb(self, mock_redis): - return RedisCircuitBreaker( - redis=mock_redis, - failure_threshold=5, - recovery_timeout=30, - ) - - @pytest.mark.asyncio - async def test_is_available_closed(self, cb, mock_redis): - mock_redis.get.return_value = None - result = await cb.is_available("service1") - assert result is True - - @pytest.mark.asyncio - async def test_is_available_open(self, cb, mock_redis): - mock_redis.get.return_value = b"open" - mock_redis.ttl.return_value = 10 - result = await cb.is_available("service1") - assert result is False - - @pytest.mark.asyncio - async def test_is_available_half_open(self, cb, mock_redis): - mock_redis.get.return_value = b"half_open" - result = await cb.is_available("service1") - assert result is True - - @pytest.mark.asyncio - async def test_record_success(self, cb, mock_redis): - await cb.record_success("service1") - mock_redis.delete.assert_awaited_once() - - @pytest.mark.asyncio - async def test_record_failure_below_threshold(self, cb, mock_redis): - mock_redis.incr.return_value = 3 - await cb.record_failure("service1") - mock_redis.incr.assert_awaited_once() - - @pytest.mark.asyncio - async def test_record_failure_above_threshold(self, cb, mock_redis): - mock_redis.incr.return_value = 6 - await cb.record_failure("service1") - mock_redis.setex.assert_awaited_once() - - @pytest.mark.asyncio - async def test_record_failure_first_time(self, cb, mock_redis): - mock_redis.incr.return_value = 1 - mock_redis.expire.return_value = True - await cb.record_failure("service1") - mock_redis.expire.assert_awaited_once() diff --git a/backend/test/unit/channel/startup/test_redis_config_reload.py b/backend/test/unit/channel/startup/test_redis_config_reload.py deleted file mode 100644 index a7e56c3d..00000000 --- a/backend/test/unit/channel/startup/test_redis_config_reload.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.infrastructure.config.redis_config_reload import RedisConfigReload - - -class TestRedisConfigReload: - @pytest.fixture - def mock_redis(self): - return AsyncMock() - - @pytest.fixture - def mock_config(self): - config = MagicMock() - config.raw_data = {} - return config - - @pytest.fixture - def reload(self, mock_redis, mock_config): - return RedisConfigReload(redis=mock_redis, config=mock_config) - - @pytest.mark.asyncio - async def test_reload(self, reload, mock_redis): - mock_redis.get.return_value = b'{"auth": {"token": "new"}}' - result = await reload.reload() - assert result == {"auth": {"token": "new"}} - - @pytest.mark.asyncio - async def test_reload_no_data(self, reload, mock_redis): - mock_redis.get.return_value = None - result = await reload.reload() - assert result == {} - - @pytest.mark.asyncio - async def test_reload_json_error(self, reload, mock_redis): - mock_redis.get.return_value = b"invalid json" - result = await reload.reload() - assert result == {} - - @pytest.mark.asyncio - async def test_publish_config_change(self, reload, mock_redis): - await reload.publish_config_change({"auth": {"token": "new"}}) - mock_redis.publish.assert_awaited_once() - - @pytest.mark.asyncio - async def test_subscribe_config_changes(self, reload, mock_redis): - mock_redis.pubsub.return_value = AsyncMock() - async_gen = reload.subscribe_config_changes() - assert async_gen is not None diff --git a/backend/test/unit/channel/startup/test_redis_content_filter.py b/backend/test/unit/channel/startup/test_redis_content_filter.py deleted file mode 100644 index dd8b2a23..00000000 --- a/backend/test/unit/channel/startup/test_redis_content_filter.py +++ /dev/null @@ -1,52 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock - -import pytest - -from yuxi.channel.infrastructure.content_filter.redis_content_filter import RedisContentFilter - - -class TestRedisContentFilter: - @pytest.fixture - def mock_redis(self): - return AsyncMock() - - @pytest.fixture - def filter(self, mock_redis): - return RedisContentFilter(redis=mock_redis) - - @pytest.mark.asyncio - async def test_check_not_blocked(self, filter, mock_redis): - mock_redis.sismember.return_value = False - result = await filter.check("Hello world") - assert result.passed is True - assert result.violations == [] - - @pytest.mark.asyncio - async def test_check_blocked(self, filter, mock_redis): - mock_redis.sismember.return_value = True - result = await filter.check("badword") - assert result.passed is False - assert len(result.violations) > 0 - - @pytest.mark.asyncio - async def test_check_exception(self, filter, mock_redis): - mock_redis.sismember.side_effect = Exception("redis error") - result = await filter.check("Hello") - assert result.passed is True - - @pytest.mark.asyncio - async def test_check_empty_text(self, filter): - result = await filter.check("") - assert result.passed is True - - @pytest.mark.asyncio - async def test_add_blocked_word(self, filter, mock_redis): - await filter.add_blocked_word("badword") - mock_redis.sadd.assert_awaited_once() - - @pytest.mark.asyncio - async def test_remove_blocked_word(self, filter, mock_redis): - await filter.remove_blocked_word("badword") - mock_redis.srem.assert_awaited_once() diff --git a/backend/test/unit/channel/startup/test_redis_pubsub.py b/backend/test/unit/channel/startup/test_redis_pubsub.py deleted file mode 100644 index 1acb341e..00000000 --- a/backend/test/unit/channel/startup/test_redis_pubsub.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.infrastructure.messaging.redis_pubsub_publisher import RedisPubSubPublisher -from yuxi.channel.infrastructure.messaging.redis_pubsub_subscriber import RedisPubSubSubscriber - - -class TestRedisPubSubPublisher: - @pytest.fixture - def mock_redis(self): - return AsyncMock() - - @pytest.fixture - def publisher(self, mock_redis): - return RedisPubSubPublisher(redis=mock_redis) - - @pytest.mark.asyncio - async def test_publish(self, publisher, mock_redis): - await publisher.publish("test_event", {"data": "test"}) - mock_redis.publish.assert_awaited_once() - - @pytest.mark.asyncio - async def test_publish_with_channel(self, publisher, mock_redis): - await publisher.publish("test_event", {"data": "test"}, channel="custom_channel") - mock_redis.publish.assert_awaited_once_with("custom_channel", '{"event_type": "test_event", "payload": {"data": "test"}}') - - -class TestRedisPubSubSubscriber: - @pytest.fixture - def mock_redis(self): - redis = AsyncMock() - pubsub = AsyncMock() - redis.pubsub.return_value = pubsub - return redis - - @pytest.fixture - def subscriber(self, mock_redis): - return RedisPubSubSubscriber(redis=mock_redis) - - @pytest.mark.asyncio - async def test_subscribe(self, subscriber, mock_redis): - mock_redis.pubsub.return_value = AsyncMock() - await subscriber.subscribe("test_channel") - mock_redis.pubsub.return_value.subscribe.assert_awaited_once_with("test_channel") - - @pytest.mark.asyncio - async def test_unsubscribe(self, subscriber, mock_redis): - pubsub = AsyncMock() - subscriber._pubsub = pubsub - await subscriber.unsubscribe("test_channel") - pubsub.unsubscribe.assert_awaited_once_with("test_channel") - - @pytest.mark.asyncio - async def test_listen(self, subscriber, mock_redis): - pubsub = AsyncMock() - pubsub.listen.return_value = [ - {"type": "message", "data": b'{"event_type": "test", "payload": {"data": "test"}}'} - ] - subscriber._pubsub = pubsub - messages = [] - async for msg in subscriber.listen(): - messages.append(msg) - break - assert len(messages) == 1 - assert messages[0]["event_type"] == "test" - - @pytest.mark.asyncio - async def test_listen_ignore_non_message(self, subscriber, mock_redis): - pubsub = AsyncMock() - pubsub.listen.return_value = [ - {"type": "subscribe", "data": 1} - ] - subscriber._pubsub = pubsub - messages = [] - async for msg in subscriber.listen(): - messages.append(msg) - break - assert len(messages) == 0 - - @pytest.mark.asyncio - async def test_stop(self, subscriber, mock_redis): - pubsub = AsyncMock() - subscriber._pubsub = pubsub - await subscriber.stop() - pubsub.close.assert_awaited_once() - assert subscriber._running is False diff --git a/backend/test/unit/channel/startup/test_redis_rate_limiter.py b/backend/test/unit/channel/startup/test_redis_rate_limiter.py deleted file mode 100644 index 86b7ca4a..00000000 --- a/backend/test/unit/channel/startup/test_redis_rate_limiter.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock - -import pytest - -from yuxi.channel.infrastructure.cache.redis_rate_limiter import RedisRateLimiter - - -class TestRedisRateLimiter: - @pytest.fixture - def mock_redis(self): - return AsyncMock() - - @pytest.fixture - def limiter(self, mock_redis): - return RedisRateLimiter( - redis=mock_redis, - max_attempts=3, - window_seconds=60, - lockout_seconds=300, - ) - - @pytest.mark.asyncio - async def test_check_and_incr_within_limit(self, limiter, mock_redis): - mock_redis.incr.return_value = 2 - result = await limiter.check_and_incr("key1") - assert result is True - mock_redis.incr.assert_awaited_once() - - @pytest.mark.asyncio - async def test_check_and_incr_exceeds_limit(self, limiter, mock_redis): - mock_redis.incr.return_value = 4 - result = await limiter.check_and_incr("key1") - assert result is False - - @pytest.mark.asyncio - async def test_is_locked(self, limiter, mock_redis): - mock_redis.get.return_value = b"5" - mock_redis.ttl.return_value = 120 - locked, ttl = await limiter.is_locked("key1") - assert locked is True - assert ttl == 120 - - @pytest.mark.asyncio - async def test_is_not_locked(self, limiter, mock_redis): - mock_redis.get.return_value = None - locked, ttl = await limiter.is_locked("key1") - assert locked is False - assert ttl == 0 - - @pytest.mark.asyncio - async def test_reset(self, limiter, mock_redis): - await limiter.reset("key1") - mock_redis.delete.assert_awaited_once_with("key1") - - @pytest.mark.asyncio - async def test_check_and_incr_first_attempt(self, limiter, mock_redis): - mock_redis.incr.return_value = 1 - mock_redis.expire.return_value = True - result = await limiter.check_and_incr("key1") - assert result is True - mock_redis.expire.assert_awaited_once() diff --git a/backend/test/unit/channel/startup/test_redis_stream_queue.py b/backend/test/unit/channel/startup/test_redis_stream_queue.py deleted file mode 100644 index 4ca69bf3..00000000 --- a/backend/test/unit/channel/startup/test_redis_stream_queue.py +++ /dev/null @@ -1,89 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.infrastructure.messaging.redis_stream_queue import RedisStreamQueue - - -class TestRedisStreamQueue: - @pytest.fixture - def mock_redis(self): - redis = AsyncMock() - redis.xadd.return_value = b"12345-0" - redis.xreadgroup.return_value = [] - redis.xack.return_value = 1 - redis.xlen.return_value = 0 - return redis - - @pytest.fixture - def queue(self, mock_redis): - return RedisStreamQueue( - redis=mock_redis, - stream_key="test_stream", - group_name="test_group", - consumer_name="test_consumer", - ) - - @pytest.mark.asyncio - async def test_enqueue(self, queue, mock_redis): - result = await queue.enqueue({"message_id": "msg1"}) - assert result == b"12345-0" - mock_redis.xadd.assert_awaited_once() - - @pytest.mark.asyncio - async def test_dequeue_empty(self, queue, mock_redis): - mock_redis.xreadgroup.return_value = [] - result = await queue.dequeue(timeout_ms=1000) - assert result == [] - - @pytest.mark.asyncio - async def test_dequeue_with_messages(self, queue, mock_redis): - mock_redis.xreadgroup.return_value = [ - (b"test_stream", [(b"12345-0", {b"payload": b'{"message_id": "msg1"}'})]) - ] - result = await queue.dequeue(timeout_ms=1000) - assert len(result) == 1 - assert result[0]["message_id"] == "msg1" - - @pytest.mark.asyncio - async def test_ack(self, queue, mock_redis): - await queue.ack("12345-0") - mock_redis.xack.assert_awaited_once_with("test_stream", "test_group", "12345-0") - - @pytest.mark.asyncio - async def test_ensure_group(self, queue, mock_redis): - mock_redis.xgroup_create.return_value = True - await queue.ensure_group() - mock_redis.xgroup_create.assert_awaited_once() - - @pytest.mark.asyncio - async def test_ensure_group_already_exists(self, queue, mock_redis): - from redis.exceptions import ResponseError - mock_redis.xgroup_create.side_effect = ResponseError("BUSYGROUP") - await queue.ensure_group() - - @pytest.mark.asyncio - async def test_queue_length(self, queue, mock_redis): - result = await queue.queue_length() - assert result == 0 - mock_redis.xlen.assert_awaited_once_with("test_stream") - - @pytest.mark.asyncio - async def test_enqueue_with_metadata(self, queue, mock_redis): - await queue.enqueue({"message_id": "msg1"}, metadata={"priority": "high"}) - call_args = mock_redis.xadd.call_args - assert call_args[1]["fields"]["metadata"] == '{"priority": "high"}' - - @pytest.mark.asyncio - async def test_dequeue_with_metadata(self, queue, mock_redis): - mock_redis.xreadgroup.return_value = [ - (b"test_stream", [(b"12345-0", { - b"payload": b'{"message_id": "msg1"}', - b"metadata": b'{"priority": "high"}' - })]) - ] - result = await queue.dequeue(timeout_ms=1000) - assert len(result) == 1 - assert result[0]["_metadata"]["priority"] == "high" diff --git a/backend/test/unit/channel/startup/test_session_factory.py b/backend/test/unit/channel/startup/test_session_factory.py deleted file mode 100644 index 5f2c9ae7..00000000 --- a/backend/test/unit/channel/startup/test_session_factory.py +++ /dev/null @@ -1,100 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.worker.session_factory import SessionFactory - - -class TestSessionFactory: - @pytest.fixture - def mock_session_repo(self): - return AsyncMock() - - @pytest.fixture - def mock_binding_repo(self): - return AsyncMock() - - @pytest.fixture - def factory(self, mock_session_repo, mock_binding_repo): - return SessionFactory( - session_repo=mock_session_repo, - binding_repo=mock_binding_repo, - default_agent_config_id=1, - ) - - @pytest.mark.asyncio - async def test_create_session(self, factory, mock_session_repo, mock_binding_repo): - mock_binding_repo.find_binding.return_value = None - mock_session_repo.get_or_create.return_value = MagicMock( - id=1, thread_id="thread123", agent_id="1" - ) - result = await factory.create( - channel_type="web", - sender_id="user1", - metadata={"is_group": False}, - ) - assert result is not None - assert result.thread_id == "thread123" - - @pytest.mark.asyncio - async def test_create_session_with_group(self, factory, mock_session_repo, mock_binding_repo): - mock_binding_repo.find_binding.return_value = None - mock_session_repo.get_or_create.return_value = MagicMock( - id=1, thread_id="thread123", agent_id="1" - ) - result = await factory.create( - channel_type="web", - sender_id="user1", - metadata={"is_group": True, "group_id": "group1"}, - ) - assert result is not None - - @pytest.mark.asyncio - async def test_create_session_with_binding(self, factory, mock_session_repo, mock_binding_repo): - from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding - - mock_binding_repo.find_binding.return_value = ChannelBinding( - id=1, - channel_type="web", - account_id="acc1", - group_id="", - agent_config_id=2, - ) - mock_session_repo.get_or_create.return_value = MagicMock( - id=1, thread_id="thread123", agent_id="2" - ) - result = await factory.create( - channel_type="web", - sender_id="user1", - metadata={"account_id": "acc1", "is_group": False}, - ) - assert result is not None - assert result.agent_id == "2" - - @pytest.mark.asyncio - async def test_create_session_with_agent_config_id(self, factory, mock_session_repo, mock_binding_repo): - mock_binding_repo.find_binding.return_value = None - mock_session_repo.get_or_create.return_value = MagicMock( - id=1, thread_id="thread123", agent_id="3" - ) - result = await factory.create( - channel_type="web", - sender_id="user1", - metadata={"is_group": False}, - agent_config_id=3, - ) - assert result is not None - assert result.agent_id == "3" - - @pytest.mark.asyncio - async def test_create_session_error(self, factory, mock_session_repo, mock_binding_repo): - mock_binding_repo.find_binding.return_value = None - mock_session_repo.get_or_create.side_effect = Exception("db error") - result = await factory.create( - channel_type="web", - sender_id="user1", - metadata={"is_group": False}, - ) - assert result is None diff --git a/backend/test/unit/channel/startup/test_session_resolver.py b/backend/test/unit/channel/startup/test_session_resolver.py deleted file mode 100644 index a0ebb7a3..00000000 --- a/backend/test/unit/channel/startup/test_session_resolver.py +++ /dev/null @@ -1,187 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.application.service.session_resolver import SessionResolver -from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding -from yuxi.channel.domain.model.session.channel_session import ChannelSession - - -class TestSessionResolver: - @pytest.fixture - def mock_session_repo(self): - return AsyncMock() - - @pytest.fixture - def mock_binding_repo(self): - return AsyncMock() - - @pytest.fixture - def resolver(self, mock_session_repo, mock_binding_repo): - return SessionResolver( - session_repo=mock_session_repo, - binding_repo=mock_binding_repo, - default_agent_config_id=1, - ) - - @pytest.fixture - def sample_payload(self): - return { - "channel_type": "web", - "sender_id": "user1", - "metadata": { - "account_id": "acc1", - "group_id": "", - "is_group": False, - }, - } - - @pytest.mark.asyncio - async def test_resolve_new_session(self, resolver, mock_session_repo, mock_binding_repo, sample_payload): - mock_binding_repo.find_binding.return_value = None - mock_session_repo.get_or_create.return_value = ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="1", - channel_type="web", - channel_session_key="user1", - status="active", - title=None, - ) - result = await resolver.resolve(sample_payload) - assert result is not None - assert result.thread_id == "thread123" - - @pytest.mark.asyncio - async def test_resolve_with_binding(self, resolver, mock_session_repo, mock_binding_repo, sample_payload): - mock_binding_repo.find_binding.return_value = ChannelBinding( - id=1, - channel_type="web", - account_id="acc1", - group_id="", - agent_config_id=2, - ) - mock_session_repo.get_or_create.return_value = ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="2", - channel_type="web", - channel_session_key="user1", - status="active", - title=None, - ) - result = await resolver.resolve(sample_payload) - assert result is not None - mock_session_repo.get_or_create.assert_awaited_once() - - @pytest.mark.asyncio - async def test_resolve_with_agent_config_id(self, resolver, mock_session_repo, mock_binding_repo, sample_payload): - sample_payload["agent_config_id"] = 3 - mock_binding_repo.find_binding.return_value = None - mock_session_repo.get_or_create.return_value = ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="3", - channel_type="web", - channel_session_key="user1", - status="active", - title=None, - ) - result = await resolver.resolve(sample_payload) - assert result is not None - - @pytest.mark.asyncio - async def test_resolve_group_session(self, resolver, mock_session_repo, mock_binding_repo, sample_payload): - sample_payload["metadata"]["is_group"] = True - sample_payload["metadata"]["group_id"] = "group1" - mock_binding_repo.find_binding.return_value = None - mock_session_repo.get_or_create.return_value = ChannelSession( - id=1, - thread_id="thread123", - user_id="user1", - agent_id="1", - channel_type="web", - channel_session_key="user1:web:group1", - status="active", - title=None, - ) - result = await resolver.resolve(sample_payload) - assert result is not None - - @pytest.mark.asyncio - async def test_resolve_session_error(self, resolver, mock_session_repo, mock_binding_repo, sample_payload): - mock_binding_repo.find_binding.return_value = None - mock_session_repo.get_or_create.side_effect = Exception("db error") - result = await resolver.resolve(sample_payload) - assert result is None - - def test_resolve_session_key_main(self, resolver): - payload = { - "channel_type": "web", - "sender_id": "user1", - "metadata": {"is_group": False}, - } - key = resolver._resolve_session_key(payload, "web", "user1", None) - assert key == "user1" - - def test_resolve_session_key_channel_group(self, resolver): - payload = { - "channel_type": "web", - "sender_id": "user1", - "metadata": {"is_group": True, "group_id": "group1"}, - } - key = resolver._resolve_session_key(payload, "web", "user1", None) - assert key == "user1:web:group1" - - def test_resolve_session_key_custom(self, resolver): - payload = { - "channel_type": "web", - "sender_id": "user1", - "metadata": { - "session_key_strategy": "custom", - "session_key_prefix": "pre", - "session_key": "custom_key", - }, - } - key = resolver._resolve_session_key(payload, "web", "user1", None) - assert key == "pre:custom_key" - - def test_resolve_session_key_with_binding(self, resolver): - binding = ChannelBinding( - id=1, - channel_type="web", - account_id="acc1", - group_id="group1", - agent_config_id=1, - session_key_strategy="main", - ) - payload = { - "channel_type": "web", - "sender_id": "user1", - "metadata": {}, - } - key = resolver._resolve_session_key(payload, "web", "user1", binding) - assert key == "user1" - - @pytest.mark.asyncio - async def test_resolve_agent_config_from_binding(self, resolver, mock_binding_repo): - mock_binding_repo.find_binding.return_value = ChannelBinding( - id=1, - channel_type="web", - account_id="acc1", - group_id="", - agent_config_id=5, - ) - result = await resolver._resolve_agent_config("web", {"account_id": "acc1", "group_id": ""}) - assert result == 5 - - @pytest.mark.asyncio - async def test_resolve_agent_config_default(self, resolver, mock_binding_repo): - mock_binding_repo.find_binding.return_value = None - result = await resolver._resolve_agent_config("web", {"account_id": "acc1", "group_id": ""}) - assert result == 1 diff --git a/backend/test/unit/channel/startup/test_setup_channel.py b/backend/test/unit/channel/startup/test_setup_channel.py deleted file mode 100644 index 5967d4db..00000000 --- a/backend/test/unit/channel/startup/test_setup_channel.py +++ /dev/null @@ -1,47 +0,0 @@ -from __future__ import annotations - -from unittest.mock import MagicMock - -import pytest -from fastapi import FastAPI - -from yuxi.channel.container import setup_channel - - -class TestSetupChannel: - def test_setup_channel_with_fastapi(self): - app = FastAPI() - container = MagicMock() - setup_channel(app, container) - assert hasattr(app.state, "channel") - assert app.state.channel is container - - def test_setup_channel_with_non_fastapi(self): - app = MagicMock() - container = MagicMock() - setup_channel(app, container) - assert not hasattr(app.state, "channel") - - def test_get_channel(self): - from yuxi.channel.container import get_channel - from fastapi import Request - - app = FastAPI() - container = MagicMock() - app.state.channel = container - - request = MagicMock(spec=Request) - request.app = app - result = get_channel(request) - assert result is container - - def test_get_channel_not_initialized(self): - from yuxi.channel.container import get_channel - from fastapi import Request, HTTPException - - app = FastAPI() - request = MagicMock(spec=Request) - request.app = app - with pytest.raises(HTTPException) as exc_info: - get_channel(request) - assert exc_info.value.status_code == 503 diff --git a/backend/test/unit/channel/startup/test_sse_endpoint.py b/backend/test/unit/channel/startup/test_sse_endpoint.py deleted file mode 100644 index e1a05114..00000000 --- a/backend/test/unit/channel/startup/test_sse_endpoint.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.interfaces.sse.endpoint import SSEEndpoint - - -class TestSSEEndpoint: - @pytest.fixture - def endpoint(self): - return SSEEndpoint() - - @pytest.mark.asyncio - async def test_start(self, endpoint): - await endpoint.start() - assert endpoint.is_running is True - - @pytest.mark.asyncio - async def test_stop(self, endpoint): - await endpoint.start() - await endpoint.stop() - assert endpoint.is_running is False - - @pytest.mark.asyncio - async def test_subscribe(self, endpoint): - await endpoint.start() - queue = await endpoint.subscribe("client1") - assert "client1" in endpoint._clients - - @pytest.mark.asyncio - async def test_unsubscribe(self, endpoint): - await endpoint.start() - queue = await endpoint.subscribe("client1") - await endpoint.unsubscribe("client1") - assert "client1" not in endpoint._clients - - @pytest.mark.asyncio - async def test_broadcast(self, endpoint): - await endpoint.start() - queue = await endpoint.subscribe("client1") - await endpoint.broadcast({"type": "message", "data": "test"}) - - @pytest.mark.asyncio - async def test_broadcast_shutdown(self, endpoint): - await endpoint.start() - queue = await endpoint.subscribe("client1") - await endpoint.broadcast_shutdown() - - @pytest.mark.asyncio - async def test_send_to_client(self, endpoint): - await endpoint.start() - queue = await endpoint.subscribe("client1") - await endpoint.send_to_client("client1", {"type": "message", "data": "test"}) - - @pytest.mark.asyncio - async def test_send_to_nonexistent_client(self, endpoint): - await endpoint.start() - await endpoint.send_to_client("nonexistent", {"type": "message", "data": "test"}) - - @pytest.mark.asyncio - async def test_get_client_queue(self, endpoint): - await endpoint.start() - queue = await endpoint.subscribe("client1") - result = endpoint.get_client_queue("client1") - assert result is not None - - def test_get_client_queue_not_found(self, endpoint): - result = endpoint.get_client_queue("nonexistent") - assert result is None diff --git a/backend/test/unit/channel/startup/test_startup_module.py b/backend/test/unit/channel/startup/test_startup_module.py deleted file mode 100644 index accbe2e9..00000000 --- a/backend/test/unit/channel/startup/test_startup_module.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from yuxi.channel.startup import get_container, init_channel, register_channel_middleware, shutdown_channel - - -class TestStartupModule: - @pytest.fixture(autouse=True) - def reset_container(self): - import yuxi.channel.startup as startup - - startup._container = None - yield - startup._container = None - - @pytest.mark.asyncio - async def test_init_channel(self): - mock_container = MagicMock() - with patch("yuxi.channel.startup.ChannelContainerFactory") as mock_factory: - mock_factory.create = AsyncMock(return_value=mock_container) - result = await init_channel(redis_url="redis://localhost") - assert result is mock_container - mock_factory.create.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_channel_with_container(self): - mock_container = AsyncMock() - await shutdown_channel(mock_container) - mock_container.shutdown.assert_awaited_once() - - @pytest.mark.asyncio - async def test_shutdown_channel_with_global(self): - import yuxi.channel.startup as startup - - mock_container = AsyncMock() - startup._container = mock_container - await shutdown_channel() - mock_container.shutdown.assert_awaited_once() - assert startup._container is None - - @pytest.mark.asyncio - async def test_shutdown_channel_none(self): - result = await shutdown_channel(None) - assert result is None - - def test_get_container_initial(self): - result = get_container() - assert result is None - - def test_get_container_after_init(self): - import yuxi.channel.startup as startup - - mock_container = MagicMock() - startup._container = mock_container - result = get_container() - assert result is mock_container - - def test_register_channel_middleware(self): - import yuxi.channel.startup as startup - - mock_container = MagicMock() - startup._container = mock_container - app = MagicMock() - app.state = MagicMock() - with patch("yuxi.channel.startup.setup_channel") as mock_setup: - register_channel_middleware(app) - mock_setup.assert_called_once_with(app, mock_container) - - def test_register_channel_middleware_no_container(self): - app = MagicMock() - with patch("yuxi.channel.startup.setup_channel") as mock_setup: - register_channel_middleware(app) - mock_setup.assert_not_called() diff --git a/backend/test/unit/channel/startup/test_startup_tracer.py b/backend/test/unit/channel/startup/test_startup_tracer.py deleted file mode 100644 index c47604c4..00000000 --- a/backend/test/unit/channel/startup/test_startup_tracer.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.container import StartupTracer - - -class TestStartupTracer: - def test_init(self): - tracer = StartupTracer() - assert tracer._events == [] - assert tracer._start_time == 0.0 - assert tracer._current_phase == "" - - def test_begin(self): - tracer = StartupTracer() - tracer.begin("config") - assert tracer._current_phase == "config" - assert tracer._start_time > 0 - - def test_end(self): - tracer = StartupTracer() - tracer.begin("config") - tracer.end() - assert len(tracer._events) == 1 - assert tracer._events[0]["name"] == "config" - assert tracer._events[0]["status"] == "ok" - assert tracer._events[0]["duration_ms"] >= 0 - - def test_mark(self): - tracer = StartupTracer() - tracer.mark("ws_feishu", "registered", duration_ms=0.5) - assert len(tracer._events) == 1 - assert tracer._events[0]["name"] == "ws_feishu" - assert tracer._events[0]["status"] == "registered" - assert tracer._events[0]["duration_ms"] == 0.5 - - def test_total_ms(self): - tracer = StartupTracer() - tracer.mark("a", "ok", duration_ms=10.0) - tracer.mark("b", "ok", duration_ms=20.5) - assert tracer.total_ms == 30.5 - - def test_to_dict(self): - tracer = StartupTracer() - tracer.mark("a", "ok", duration_ms=5.0) - result = tracer.to_dict() - assert result == [{"name": "a", "status": "ok", "duration_ms": 5.0}] - - def test_multiple_phases(self): - tracer = StartupTracer() - tracer.begin("config") - tracer.end() - tracer.begin("redis") - tracer.end() - tracer.begin("infra") - tracer.end() - assert len(tracer._events) == 3 - assert tracer._events[0]["name"] == "config" - assert tracer._events[1]["name"] == "redis" - assert tracer._events[2]["name"] == "infra" - assert tracer.total_ms == sum(e["duration_ms"] for e in tracer._events) diff --git a/backend/test/unit/channel/startup/test_unified_message.py b/backend/test/unit/channel/startup/test_unified_message.py deleted file mode 100644 index f8d98371..00000000 --- a/backend/test/unit/channel/startup/test_unified_message.py +++ /dev/null @@ -1,107 +0,0 @@ -from __future__ import annotations - -import pytest - -from yuxi.channel.domain.model.message.unified_message import UnifiedMessage -from yuxi.channel.domain.model.message.peer import Peer -from yuxi.channel.domain.model.message.attachment import Attachment - - -class TestUnifiedMessage: - def test_basic_creation(self): - msg = UnifiedMessage( - message_id="msg123", - channel_type="web", - sender=Peer(id="user1", name="User"), - content="Hello", - ) - assert msg.message_id == "msg123" - assert msg.channel_type == "web" - assert msg.content == "Hello" - - def test_to_dict(self): - msg = UnifiedMessage( - message_id="msg123", - channel_type="web", - sender=Peer(id="user1", name="User"), - content="Hello", - metadata={"key": "value"}, - ) - d = msg.to_dict() - assert d["message_id"] == "msg123" - assert d["content"] == "Hello" - assert d["metadata"] == {"key": "value"} - - def test_from_dict(self): - d = { - "message_id": "msg123", - "channel_type": "web", - "sender": {"id": "user1", "name": "User"}, - "content": "Hello", - "metadata": {"key": "value"}, - } - msg = UnifiedMessage.from_dict(d) - assert msg.message_id == "msg123" - assert msg.sender.id == "user1" - assert msg.metadata == {"key": "value"} - - def test_with_attachments(self): - msg = UnifiedMessage( - message_id="msg123", - channel_type="web", - sender=Peer(id="user1", name="User"), - content="Hello", - attachments=[ - Attachment(url="http://example.com/image.png", media_type="image", filename="image.png"), - ], - ) - assert len(msg.attachments) == 1 - assert msg.attachments[0].media_type == "image" - - def test_empty_message(self): - msg = UnifiedMessage( - message_id="", - channel_type="", - sender=Peer(id="", name=""), - content="", - ) - assert msg.message_id == "" - assert msg.content == "" - - -class TestPeer: - def test_creation(self): - peer = Peer(id="user1", name="User") - assert peer.id == "user1" - assert peer.name == "User" - - def test_to_dict(self): - peer = Peer(id="user1", name="User") - d = peer.to_dict() - assert d == {"id": "user1", "name": "User"} - - def test_from_dict(self): - d = {"id": "user1", "name": "User"} - peer = Peer.from_dict(d) - assert peer.id == "user1" - assert peer.name == "User" - - -class TestAttachment: - def test_creation(self): - att = Attachment(url="http://example.com/file.pdf", media_type="document", filename="file.pdf") - assert att.url == "http://example.com/file.pdf" - assert att.media_type == "document" - assert att.filename == "file.pdf" - - def test_to_dict(self): - att = Attachment(url="http://example.com/file.pdf", media_type="document", filename="file.pdf") - d = att.to_dict() - assert d["url"] == "http://example.com/file.pdf" - assert d["media_type"] == "document" - - def test_from_dict(self): - d = {"url": "http://example.com/file.pdf", "media_type": "document", "filename": "file.pdf"} - att = Attachment.from_dict(d) - assert att.url == "http://example.com/file.pdf" - assert att.filename == "file.pdf" diff --git a/backend/test/unit/channel/startup/test_web_adapter.py b/backend/test/unit/channel/startup/test_web_adapter.py deleted file mode 100644 index 0cc78efc..00000000 --- a/backend/test/unit/channel/startup/test_web_adapter.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.channels.web.adapter import WebAdapter -from yuxi.channel.domain.model.message.unified_message import UnifiedMessage -from yuxi.channel.domain.model.message.peer import Peer -from yuxi.channel.domain.model.message.dispatch_result import SendResult - - -class TestWebAdapter: - @pytest.fixture - def adapter(self): - return WebAdapter(config={"sse_enabled": True, "max_connections": 500}) - - @pytest.mark.asyncio - async def test_open(self, adapter): - await adapter.open() - assert adapter.is_open is True - - @pytest.mark.asyncio - async def test_close(self, adapter): - await adapter.open() - await adapter.close() - assert adapter.is_open is False - - @pytest.mark.asyncio - async def test_send_message(self, adapter): - await adapter.open() - msg = UnifiedMessage( - message_id="msg123", - channel_type="web", - sender=Peer(id="bot", name="Bot"), - content="Hello", - ) - result = await adapter.send_message("thread123", msg) - assert isinstance(result, SendResult) - - @pytest.mark.asyncio - async def test_send_media(self, adapter): - await adapter.open() - result = await adapter.send_media("thread123", "http://example.com/image.png", "image") - assert isinstance(result, SendResult) - - @pytest.mark.asyncio - async def test_send_typing(self, adapter): - await adapter.open() - result = await adapter.send_typing("thread123") - assert isinstance(result, SendResult) - - def test_capabilities(self, adapter): - assert adapter.capabilities.text is True - assert adapter.capabilities.media is True - assert adapter.capabilities.typing is True - assert adapter.capabilities.group is True - - def test_bot_id(self, adapter): - assert adapter.bot_id is None - - def test_ws_connection(self, adapter): - assert adapter.ws_connection is None - - @pytest.mark.asyncio - async def test_send_message_not_open(self, adapter): - msg = UnifiedMessage( - message_id="msg123", - channel_type="web", - sender=Peer(id="bot", name="Bot"), - content="Hello", - ) - result = await adapter.send_message("thread123", msg) - assert result.success is False diff --git a/backend/test/unit/channel/startup/test_worker_pool.py b/backend/test/unit/channel/startup/test_worker_pool.py deleted file mode 100644 index 8d4a9273..00000000 --- a/backend/test/unit/channel/startup/test_worker_pool.py +++ /dev/null @@ -1,78 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.worker.pool import WorkerPool, WorkerPoolConfig - - -class TestWorkerPool: - @pytest.fixture - def mock_queue(self): - return AsyncMock() - - @pytest.fixture - def mock_dispatch_fn(self): - return AsyncMock() - - @pytest.fixture - def worker_pool(self, mock_queue, mock_dispatch_fn): - return WorkerPool( - queue_port=mock_queue, - dispatch_fn=mock_dispatch_fn, - config=WorkerPoolConfig(num_workers=2, max_concurrent=5), - ) - - @pytest.mark.asyncio - async def test_start(self, worker_pool, mock_queue): - await worker_pool.start() - assert worker_pool.is_running is True - mock_queue.ensure_group.assert_awaited_once() - - @pytest.mark.asyncio - async def test_stop(self, worker_pool): - await worker_pool.start() - await worker_pool.stop() - assert worker_pool.is_running is False - - @pytest.mark.asyncio - async def test_is_running_property(self, worker_pool): - assert worker_pool.is_running is False - await worker_pool.start() - assert worker_pool.is_running is True - - @pytest.mark.asyncio - async def test_dispatch_loop(self, worker_pool, mock_queue): - mock_queue.dequeue.return_value = [{"message_id": "msg1", "session_id": "sess1"}] - await worker_pool.start() - await worker_pool.stop() - - @pytest.mark.asyncio - async def test_worker_loop(self, worker_pool, mock_queue, mock_dispatch_fn): - msg = {"message_id": "msg1", "session_id": "sess1", "_stream_id": "stream1"} - mock_queue.dequeue.return_value = [msg] - await worker_pool.start() - await worker_pool.stop() - - @pytest.mark.asyncio - async def test_route_with_session_id(self, worker_pool): - msg = {"session_id": "test_session"} - await worker_pool._route(msg) - - @pytest.mark.asyncio - async def test_route_without_session_id(self, worker_pool): - msg = {} - await worker_pool._route(msg) - - def test_config_defaults(self): - config = WorkerPoolConfig() - assert config.num_workers == 4 - assert config.max_concurrent == 20 - assert config.poll_timeout_ms == 5000 - - def test_config_custom(self): - config = WorkerPoolConfig(num_workers=2, max_concurrent=10, poll_timeout_ms=3000) - assert config.num_workers == 2 - assert config.max_concurrent == 10 - assert config.poll_timeout_ms == 3000 diff --git a/backend/test/unit/channel/startup/test_ws_manager.py b/backend/test/unit/channel/startup/test_ws_manager.py deleted file mode 100644 index 299afa20..00000000 --- a/backend/test/unit/channel/startup/test_ws_manager.py +++ /dev/null @@ -1,87 +0,0 @@ -from __future__ import annotations - -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from yuxi.channel.interfaces.websocket.manager import WSManager - - -class TestWSManager: - @pytest.fixture - def manager(self): - return WSManager() - - @pytest.mark.asyncio - async def test_start(self, manager): - await manager.start() - assert manager.is_running is True - - @pytest.mark.asyncio - async def test_stop(self, manager): - await manager.start() - await manager.stop() - assert manager.is_running is False - - @pytest.mark.asyncio - async def test_stop_all(self, manager): - await manager.start() - ws = AsyncMock() - manager._connections["conn1"] = ws - await manager.stop_all() - ws.close.assert_awaited_once() - assert len(manager._connections) == 0 - - @pytest.mark.asyncio - async def test_register(self, manager): - await manager.start() - ws = AsyncMock() - await manager.register("conn1", ws) - assert "conn1" in manager._connections - - @pytest.mark.asyncio - async def test_unregister(self, manager): - await manager.start() - ws = AsyncMock() - await manager.register("conn1", ws) - await manager.unregister("conn1") - assert "conn1" not in manager._connections - - @pytest.mark.asyncio - async def test_send_to(self, manager): - await manager.start() - ws = AsyncMock() - await manager.register("conn1", ws) - await manager.send_to("conn1", {"type": "message"}) - ws.send.assert_awaited_once() - - @pytest.mark.asyncio - async def test_send_to_nonexistent(self, manager): - await manager.start() - await manager.send_to("nonexistent", {"type": "message"}) - - @pytest.mark.asyncio - async def test_broadcast(self, manager): - await manager.start() - ws1 = AsyncMock() - ws2 = AsyncMock() - await manager.register("conn1", ws1) - await manager.register("conn2", ws2) - await manager.broadcast({"type": "message"}) - ws1.send.assert_awaited_once() - ws2.send.assert_awaited_once() - - @pytest.mark.asyncio - async def test_get_connection(self, manager): - await manager.start() - ws = AsyncMock() - await manager.register("conn1", ws) - result = manager.get_connection("conn1") - assert result is ws - - def test_get_connection_not_found(self, manager): - result = manager.get_connection("nonexistent") - assert result is None - - def test_connection_count(self, manager): - assert manager.connection_count == 0 diff --git a/backend/test/unit/channel/worker/__init__.py b/backend/test/unit/channel/worker/__init__.py index 5f282702..e69de29b 100644 --- a/backend/test/unit/channel/worker/__init__.py +++ b/backend/test/unit/channel/worker/__init__.py @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/backend/test/unit/channel/worker/test_outbox_retry.py b/backend/test/unit/channel/worker/test_outbox_retry.py index 4c275b4d..f58581b2 100644 --- a/backend/test/unit/channel/worker/test_outbox_retry.py +++ b/backend/test/unit/channel/worker/test_outbox_retry.py @@ -7,13 +7,14 @@ import pytest from yuxi.channel.domain.model.message.dispatch_result import SendResult from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry +from yuxi.channel.domain.model.outbox.outbox_status import OutboxStatus from yuxi.channel.worker.outbox_retry import OutboxRetryWorker, _OUTBOX_NOTIFY_CHANNEL @pytest.fixture def mock_outbox_repo(): repo = MagicMock() - repo.fetch_pending = AsyncMock(return_value=[]) + repo.fetch_and_lock = AsyncMock(return_value=[]) repo.mark_sent = AsyncMock(return_value=True) repo.mark_dead = AsyncMock(return_value=True) repo.mark_retrying = AsyncMock(return_value=None) @@ -66,7 +67,7 @@ def outbox_entry(): session_id="sess-1", channel_type="web", content="hello", - status="pending", + status=OutboxStatus.PROCESSING, retry_count=0, max_retries=3, next_retry_at=None, @@ -102,7 +103,7 @@ async def test_outbox_retry_start_stop(outbox_worker): @pytest.mark.unit async def test_process_pending_success(outbox_worker, mock_outbox_repo, mock_adapter, mock_metrics, outbox_entry): - mock_outbox_repo.fetch_pending = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) + mock_outbox_repo.fetch_and_lock = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) await outbox_worker.start() await asyncio.sleep(0.2) @@ -119,9 +120,26 @@ async def test_process_pending_success(outbox_worker, mock_outbox_repo, mock_ada @pytest.mark.unit -async def test_process_pending_failure_then_retry(outbox_worker, mock_outbox_repo, mock_adapter, mock_metrics, outbox_entry): +async def test_process_pending_failure_then_retry( + outbox_worker, mock_outbox_repo, mock_adapter, mock_metrics, outbox_entry +): mock_adapter.send_message = AsyncMock(return_value=SendResult(success=False, error="network error")) - mock_outbox_repo.fetch_pending = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) + mock_outbox_repo.fetch_and_lock = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) + retrying_entry = OutboxEntry( + id=1, + message_id="msg-1", + session_id="sess-1", + channel_type="web", + content="hello", + status=OutboxStatus.RETRYING, + retry_count=1, + max_retries=3, + next_retry_at=None, + last_error="network error", + trace_id="trace-1", + extra_metadata=None, + ) + mock_outbox_repo.mark_retrying = AsyncMock(return_value=retrying_entry) await outbox_worker.start() await asyncio.sleep(0.2) @@ -139,7 +157,7 @@ async def test_process_pending_failure_then_dead(outbox_worker, mock_outbox_repo session_id="sess-2", channel_type="web", content="hello", - status="pending", + status=OutboxStatus.PROCESSING, retry_count=2, max_retries=3, next_retry_at=None, @@ -148,20 +166,35 @@ async def test_process_pending_failure_then_dead(outbox_worker, mock_outbox_repo extra_metadata=None, ) mock_adapter.send_message = AsyncMock(return_value=SendResult(success=False, error="network error")) - mock_outbox_repo.fetch_pending = AsyncMock(side_effect=_make_fetch_side_effect([entry])) + mock_outbox_repo.fetch_and_lock = AsyncMock(side_effect=_make_fetch_side_effect([entry])) + dead_entry = OutboxEntry( + id=2, + message_id="msg-2", + session_id="sess-2", + channel_type="web", + content="hello", + status=OutboxStatus.DEAD, + retry_count=3, + max_retries=3, + next_retry_at=None, + last_error="network error", + trace_id="trace-2", + extra_metadata=None, + ) + mock_outbox_repo.mark_retrying = AsyncMock(return_value=dead_entry) await outbox_worker.start() await asyncio.sleep(0.2) await outbox_worker.stop() - mock_outbox_repo.mark_dead.assert_awaited_once_with(2, last_error="network error") + mock_outbox_repo.mark_retrying.assert_awaited_once_with(2, last_error="network error") mock_metrics.record_outbox_retry_total.assert_awaited_once_with("web", "dead") @pytest.mark.unit async def test_process_pending_adapter_not_found(outbox_worker, mock_outbox_repo, mock_adapter, outbox_entry): outbox_entry.channel_type = "unknown" - mock_outbox_repo.fetch_pending = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) + mock_outbox_repo.fetch_and_lock = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) await outbox_worker.start() await asyncio.sleep(0.2) @@ -180,7 +213,7 @@ async def test_process_pending_no_metrics(mock_outbox_repo, mock_adapter, outbox metrics=None, poll_interval=0.1, ) - mock_outbox_repo.fetch_pending = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) + mock_outbox_repo.fetch_and_lock = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) await worker.start() await asyncio.sleep(0.2) @@ -219,36 +252,10 @@ async def test_listen_notifications(mock_redis, mock_outbox_repo, mock_adapter): @pytest.mark.unit async def test_loop_error_handling(outbox_worker, mock_outbox_repo): - mock_outbox_repo.fetch_pending = AsyncMock(side_effect=Exception("db error")) + mock_outbox_repo.fetch_and_lock = AsyncMock(side_effect=Exception("db error")) await outbox_worker.start() await asyncio.sleep(0.2) await outbox_worker.stop() - assert mock_outbox_repo.fetch_pending.await_count >= 1 - - -@pytest.mark.unit -async def test_process_pending_max_retries_exceeded_no_error(outbox_worker, mock_outbox_repo, mock_adapter, mock_metrics): - entry = OutboxEntry( - id=3, - message_id="msg-3", - session_id="sess-3", - channel_type="web", - content="hello", - status="pending", - retry_count=2, - max_retries=3, - next_retry_at=None, - last_error=None, - trace_id=None, - extra_metadata=None, - ) - mock_adapter.send_message = AsyncMock(return_value=SendResult(success=False, error="")) - mock_outbox_repo.fetch_pending = AsyncMock(side_effect=_make_fetch_side_effect([entry])) - - await outbox_worker.start() - await asyncio.sleep(0.2) - await outbox_worker.stop() - - mock_outbox_repo.mark_dead.assert_awaited_once_with(3, last_error="max_retries_exceeded") + assert mock_outbox_repo.fetch_and_lock.await_count >= 1 diff --git a/docs/develop-guides/roadmap.md b/docs/develop-guides/roadmap.md index 5a11366c..e503db95 100644 --- a/docs/develop-guides/roadmap.md +++ b/docs/develop-guides/roadmap.md @@ -36,7 +36,37 @@ ### 0.6.3 开发记录 - +- 多渠道网关 Router 渠道适配器优化(DDD 重构) + - C04: Container 添加 Registry 引用、快照方法、require() 方法,显式化容器字段 + - C04-B: DeliveryService/OutboxRetryWorker/WsConnectionManager 迁移到 Registry 读取适配器 + - C04-C: 路由层移除直接操作 adapters,统一通过 PluginRegistryAppService 访问 + - R01: Metrics 端点添加认证 + - R02: 公共方法 resolve_config() 替代私有属性访问 + - R03: 新增 ActiveSessionExistsException,deactivate 时检查活跃会话 + - R04: 重复绑定校验(DuplicateBindingException + IntegrityError 捕获) + - R05: 正向缓存写入(create_binding 后写缓存而非删缓存) + - R06: ConfigService 注入 AuthService,配置热更新时同步 auth_password 凭证 + - R09: SSE ticket 机制(POST /channel/sse/ticket 获取一次性 ticket,GET /channel/sse 用 ticket 兑换) + - R10: 修正 can_activate 逻辑,新增 can_deactivate/can_reload 状态检查 + - R11: Channel Status 补全 active_sessions/ws_connected 字段 + - R12: Channel Status 并发检查(asyncio.gather 并行 is_healthy + count_sessions) + - R13: Readyz 使用 pg_manager.is_initialized 公共属性 + - R14: Readyz 并发检查各组件 + - R15: Readyz 支持 degraded 状态(200 返回) + - R16: Bindings PATCH 端点(部分更新 agent_config_id/is_enabled) + - R19: 插件列表 status 参数使用 Literal 类型校验 + - R20: Binding 创建时外键校验(AgentConfigLookupPort) + - R21: 通用异常固定错误消息(InvalidPluginStateException/ActiveSessionExistsException) + - R22: SSE 单 session 连接数限制(MAX_CONNECTIONS_PER_SESSION=5) + - R23: SSE JSON 转义(json.dumps 替代 f-string) + - R24: Event publish 失败日志级别降为 warning + - R25/R30: 容器字段显式化(plugin_registry_impl/plugin_app_service) + - R26: 插件 manifest/config-schema 端点添加 response_model + - R28: healthz timeline 通过环境变量控制 + - R29: 审计追踪(channel_auth_operator_depends 返回操作者标识) + - R31: activate/deactivate/reload 路由调用 can_* 方法前置校验 + - C02: 新增 ErrorResponse 统一错误响应模型 + - C03: Container.require() 统一 None 检查 ---