From 6c95dc006aa3f11ad0029f2aa9072fb498fe39e4 Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Sat, 30 May 2026 21:55:35 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=96=B0=E5=A2=9E=E6=B8=A0=E9=81=93?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E5=85=A8=E9=93=BE=E8=B7=AF=E5=8D=95=E5=85=83?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E7=94=A8=E4=BE=8B=E4=B8=8E=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完成渠道模块的单元测试目录搭建,新增多个领域模型、端口、中间件、事件、服务以及基础设施层的单元测试文件,同时补充了conftest.py的环境变量配置,完善测试基础环境。 --- backend/test/conftest.py | 1 + .../test/integration/api/channel/__init__.py | 1 + backend/test/unit/channel/__init__.py | 1 + .../test/unit/channel/application/__init__.py | 1 + .../unit/channel/application/dto/__init__.py | 1 + .../application/dto/test_channel_dto.py | 95 +++ .../channel/application/pipeline/__init__.py | 1 + .../pipeline/middlewares/__init__.py | 1 + .../test_access_policy_middleware.py | 168 ++++ .../middlewares/test_auth_middleware.py | 103 +++ .../middlewares/test_dedup_middleware.py | 127 +++ .../middlewares/test_enqueue_mq_middleware.py | 117 +++ .../test_keyword_filter_middleware.py | 103 +++ .../test_mention_gate_middleware.py | 131 ++++ .../middlewares/test_rate_limit_middleware.py | 109 +++ .../middlewares/test_validation_middleware.py | 172 ++++ .../application/pipeline/test_builder.py | 179 +++++ .../channel/application/service/__init__.py | 1 + .../application/service/test_auth_service.py | 153 ++++ .../service/test_binding_service.py | 170 ++++ .../service/test_config_service.py | 143 ++++ .../service/test_delivery_service.py | 368 +++++++++ .../service/test_dispatch_service.py | 280 +++++++ .../service/test_inbound_service.py | 166 ++++ .../service/test_session_resolver.py | 362 +++++++++ .../test/unit/channel/channels/__init__.py | 1 + .../channel/channels/dingtalk/__init__.py | 1 + .../unit/channel/channels/feishu/__init__.py | 1 + .../unit/channel/channels/hooks/__init__.py | 1 + .../unit/channel/channels/web/__init__.py | 1 + .../test/unit/channel/container/__init__.py | 1 + .../test_container_channel_container.py | 83 ++ ...t_container_factory_apply_env_overrides.py | 52 ++ .../test_container_factory_build_adapters.py | 75 ++ ...st_container_factory_build_auth_service.py | 56 ++ .../test_container_factory_build_config.py | 27 + .../test_container_factory_build_infra.py | 42 + .../test_container_factory_build_pipeline.py | 53 ++ .../test_container_factory_build_workers.py | 86 ++ ..._container_factory_build_ws_connections.py | 57 ++ .../test_container_factory_create.py | 80 ++ .../test_container_factory_inject_bot_ids.py | 41 + .../test_container_resolve_agent_id.py | 46 ++ .../container/test_container_setup_and_get.py | 51 ++ .../container/test_container_startup.py | 79 ++ .../test_container_startup_tracer.py | 66 ++ backend/test/unit/channel/domain/__init__.py | 1 + .../unit/channel/domain/event/__init__.py | 1 + .../channel/domain/event/test_agent_error.py | 34 + .../domain/event/test_gateway_shutdown.py | 18 + .../domain/event/test_message_blocked.py | 34 + .../domain/event/test_message_received.py | 34 + .../domain/event/test_message_replied.py | 31 + .../unit/channel/domain/exception/__init__.py | 1 + .../domain/exception/test_abort_mapping.py | 31 + .../domain/exception/test_channel_error.py | 44 ++ .../channel/domain/middleware/__init__.py | 1 + .../domain/middleware/test_configurable.py | 17 + .../domain/middleware/test_middleware.py | 23 + .../unit/channel/domain/model/__init__.py | 1 + .../channel/domain/model/binding/__init__.py | 1 + .../model/binding/test_channel_binding.py | 79 ++ .../channel/domain/model/message/__init__.py | 1 + .../domain/model/message/test_attachment.py | 25 + .../model/message/test_dispatch_result.py | 33 + .../channel/domain/model/message/test_peer.py | 15 + .../model/message/test_stream_chat_request.py | 32 + .../model/message/test_unified_message.py | 60 ++ .../channel/domain/model/outbox/__init__.py | 1 + .../domain/model/outbox/test_outbox_entry.py | 55 ++ .../channel/domain/model/session/__init__.py | 1 + .../model/session/test_channel_session.py | 41 + .../channel/domain/model/shared/__init__.py | 1 + .../model/shared/test_channel_capabilities.py | 41 + .../domain/model/shared/test_channel_type.py | 14 + .../model/shared/test_content_chunker.py | 36 + .../model/shared/test_message_priority.py | 13 + .../domain/model/shared/test_message_type.py | 14 + .../test/unit/channel/domain/port/__init__.py | 1 + .../channel/domain/port/test_agent_port.py | 25 + .../domain/port/test_bot_loop_guard_port.py | 21 + .../channel/domain/port/test_cache_port.py | 41 + .../domain/port/test_channel_adapter_port.py | 71 ++ .../port/test_channel_route_contributor.py | 18 + .../domain/port/test_circuit_breaker_port.py | 25 + .../domain/port/test_config_reload_port.py | 27 + .../domain/port/test_content_filter_port.py | 38 + .../domain/port/test_event_publisher_port.py | 28 + .../domain/port/test_keyword_matcher_port.py | 21 + .../channel/domain/port/test_metrics_port.py | 85 ++ .../channel/domain/port/test_queue_port.py | 29 + .../domain/port/test_rate_limit_port.py | 27 + .../domain/port/test_signature_verify_port.py | 17 + .../channel/domain/port/test_sse_push_port.py | 17 + .../domain/port/test_ws_connection_port.py | 67 ++ .../channel/domain/repository/__init__.py | 1 + .../repository/test_binding_repository.py | 69 ++ .../repository/test_message_log_repository.py | 138 ++++ .../repository/test_message_repository.py | 65 ++ .../repository/test_outbox_repository.py | 74 ++ .../repository/test_session_repository.py | 41 + .../unit/channel/domain/service/__init__.py | 1 + .../domain/service/test_message_context.py | 81 ++ .../channel/domain/service/test_pipeline.py | 181 +++++ .../domain/service/test_pipeline_builder.py | 63 ++ .../unit/channel/infrastructure/__init__.py | 1 + .../channel/infrastructure/agent/__init__.py | 1 + .../agent/test_agent_adapter.py | 148 ++++ .../channel/infrastructure/config/__init__.py | 1 + .../config/test_channel_config.py | 113 +++ .../config/test_redis_config_reload.py | 116 +++ .../infrastructure/content_filter/__init__.py | 1 + .../test_composite_content_filter.py | 59 ++ .../content_filter/test_llm_content_filter.py | 39 + .../test_redis_content_filter.py | 75 ++ .../matching/test_aho_corasick_matcher.py | 54 ++ .../infrastructure/messaging/__init__.py | 1 + .../messaging/test_redis_pubsub_publisher.py | 113 +++ .../messaging/test_redis_pubsub_subscriber.py | 147 ++++ .../messaging/test_redis_stream_queue.py | 126 +++ .../infrastructure/metrics/__init__.py | 1 + .../metrics/test_prometheus_metrics.py | 105 +++ .../infrastructure/persistence/__init__.py | 1 + .../persistence/converter/__init__.py | 1 + .../converter/test_binding_converter.py | 55 ++ .../persistence/repository/__init__.py | 1 + .../repository/test_pg_binding_repository.py | 239 ++++++ .../test_pg_message_log_repository.py | 219 ++++++ .../repository/test_pg_message_repository.py | 83 ++ .../repository/test_pg_outbox_repository.py | 225 ++++++ .../repository/test_pg_session_repository.py | 145 ++++ .../infrastructure/security/__init__.py | 1 + .../security/test_hmac_signature_verifier.py | 86 ++ .../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 | 163 ++++ .../interfaces/rest/middleware/__init__.py | 1 + .../rest/middleware/test_exception_handler.py | 64 ++ .../rest/middleware/test_trace_id.py | 72 ++ .../interfaces/rest/router/__init__.py | 1 + .../interfaces/rest/router/test_bindings.py | 187 +++++ .../rest/router/test_channel_status.py | 155 ++++ .../rest/router/test_config_reload.py | 88 +++ .../interfaces/rest/router/test_health.py | 229 ++++++ .../interfaces/rest/router/test_metrics.py | 87 +++ .../interfaces/rest/router/test_registry.py | 79 ++ .../interfaces/rest/router/test_sse.py | 93 +++ .../unit/channel/interfaces/sse/__init__.py | 1 + .../channel/interfaces/sse/test_endpoint.py | 152 ++++ .../channel/interfaces/websocket/__init__.py | 1 + .../interfaces/websocket/test_manager.py | 229 ++++++ backend/test/unit/channel/startup/__init__.py | 0 .../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 | 254 ++++++ backend/test/unit/channel/worker/test_pool.py | 227 ++++++ .../channel/worker/test_session_factory.py | 38 + 203 files changed, 15050 insertions(+) create mode 100644 backend/test/integration/api/channel/__init__.py create mode 100644 backend/test/unit/channel/__init__.py create mode 100644 backend/test/unit/channel/application/__init__.py create mode 100644 backend/test/unit/channel/application/dto/__init__.py create mode 100644 backend/test/unit/channel/application/dto/test_channel_dto.py create mode 100644 backend/test/unit/channel/application/pipeline/__init__.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/__init__.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/test_access_policy_middleware.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/test_auth_middleware.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/test_dedup_middleware.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/test_enqueue_mq_middleware.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/test_keyword_filter_middleware.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/test_mention_gate_middleware.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/test_rate_limit_middleware.py create mode 100644 backend/test/unit/channel/application/pipeline/middlewares/test_validation_middleware.py create mode 100644 backend/test/unit/channel/application/pipeline/test_builder.py create mode 100644 backend/test/unit/channel/application/service/__init__.py create mode 100644 backend/test/unit/channel/application/service/test_auth_service.py create mode 100644 backend/test/unit/channel/application/service/test_binding_service.py create mode 100644 backend/test/unit/channel/application/service/test_config_service.py create mode 100644 backend/test/unit/channel/application/service/test_delivery_service.py create mode 100644 backend/test/unit/channel/application/service/test_dispatch_service.py create mode 100644 backend/test/unit/channel/application/service/test_inbound_service.py create mode 100644 backend/test/unit/channel/application/service/test_session_resolver.py create mode 100644 backend/test/unit/channel/channels/__init__.py create mode 100644 backend/test/unit/channel/channels/dingtalk/__init__.py create mode 100644 backend/test/unit/channel/channels/feishu/__init__.py create mode 100644 backend/test/unit/channel/channels/hooks/__init__.py create mode 100644 backend/test/unit/channel/channels/web/__init__.py create mode 100644 backend/test/unit/channel/container/__init__.py create mode 100644 backend/test/unit/channel/container/test_container_channel_container.py create mode 100644 backend/test/unit/channel/container/test_container_factory_apply_env_overrides.py create mode 100644 backend/test/unit/channel/container/test_container_factory_build_adapters.py create mode 100644 backend/test/unit/channel/container/test_container_factory_build_auth_service.py create mode 100644 backend/test/unit/channel/container/test_container_factory_build_config.py create mode 100644 backend/test/unit/channel/container/test_container_factory_build_infra.py create mode 100644 backend/test/unit/channel/container/test_container_factory_build_pipeline.py create mode 100644 backend/test/unit/channel/container/test_container_factory_build_workers.py create mode 100644 backend/test/unit/channel/container/test_container_factory_build_ws_connections.py create mode 100644 backend/test/unit/channel/container/test_container_factory_create.py create mode 100644 backend/test/unit/channel/container/test_container_factory_inject_bot_ids.py create mode 100644 backend/test/unit/channel/container/test_container_resolve_agent_id.py create mode 100644 backend/test/unit/channel/container/test_container_setup_and_get.py create mode 100644 backend/test/unit/channel/container/test_container_startup.py create mode 100644 backend/test/unit/channel/container/test_container_startup_tracer.py create mode 100644 backend/test/unit/channel/domain/__init__.py create mode 100644 backend/test/unit/channel/domain/event/__init__.py create mode 100644 backend/test/unit/channel/domain/event/test_agent_error.py create mode 100644 backend/test/unit/channel/domain/event/test_gateway_shutdown.py create mode 100644 backend/test/unit/channel/domain/event/test_message_blocked.py create mode 100644 backend/test/unit/channel/domain/event/test_message_received.py create mode 100644 backend/test/unit/channel/domain/event/test_message_replied.py create mode 100644 backend/test/unit/channel/domain/exception/__init__.py create mode 100644 backend/test/unit/channel/domain/exception/test_abort_mapping.py create mode 100644 backend/test/unit/channel/domain/exception/test_channel_error.py create mode 100644 backend/test/unit/channel/domain/middleware/__init__.py create mode 100644 backend/test/unit/channel/domain/middleware/test_configurable.py create mode 100644 backend/test/unit/channel/domain/middleware/test_middleware.py create mode 100644 backend/test/unit/channel/domain/model/__init__.py create mode 100644 backend/test/unit/channel/domain/model/binding/__init__.py create mode 100644 backend/test/unit/channel/domain/model/binding/test_channel_binding.py create mode 100644 backend/test/unit/channel/domain/model/message/__init__.py create mode 100644 backend/test/unit/channel/domain/model/message/test_attachment.py create mode 100644 backend/test/unit/channel/domain/model/message/test_dispatch_result.py create mode 100644 backend/test/unit/channel/domain/model/message/test_peer.py create mode 100644 backend/test/unit/channel/domain/model/message/test_stream_chat_request.py create mode 100644 backend/test/unit/channel/domain/model/message/test_unified_message.py create mode 100644 backend/test/unit/channel/domain/model/outbox/__init__.py create mode 100644 backend/test/unit/channel/domain/model/outbox/test_outbox_entry.py create mode 100644 backend/test/unit/channel/domain/model/session/__init__.py create mode 100644 backend/test/unit/channel/domain/model/session/test_channel_session.py create mode 100644 backend/test/unit/channel/domain/model/shared/__init__.py create mode 100644 backend/test/unit/channel/domain/model/shared/test_channel_capabilities.py create mode 100644 backend/test/unit/channel/domain/model/shared/test_channel_type.py create mode 100644 backend/test/unit/channel/domain/model/shared/test_content_chunker.py create mode 100644 backend/test/unit/channel/domain/model/shared/test_message_priority.py create mode 100644 backend/test/unit/channel/domain/model/shared/test_message_type.py create mode 100644 backend/test/unit/channel/domain/port/__init__.py create mode 100644 backend/test/unit/channel/domain/port/test_agent_port.py create mode 100644 backend/test/unit/channel/domain/port/test_bot_loop_guard_port.py create mode 100644 backend/test/unit/channel/domain/port/test_cache_port.py create mode 100644 backend/test/unit/channel/domain/port/test_channel_adapter_port.py create mode 100644 backend/test/unit/channel/domain/port/test_channel_route_contributor.py create mode 100644 backend/test/unit/channel/domain/port/test_circuit_breaker_port.py create mode 100644 backend/test/unit/channel/domain/port/test_config_reload_port.py create mode 100644 backend/test/unit/channel/domain/port/test_content_filter_port.py create mode 100644 backend/test/unit/channel/domain/port/test_event_publisher_port.py create mode 100644 backend/test/unit/channel/domain/port/test_keyword_matcher_port.py create mode 100644 backend/test/unit/channel/domain/port/test_metrics_port.py create mode 100644 backend/test/unit/channel/domain/port/test_queue_port.py create mode 100644 backend/test/unit/channel/domain/port/test_rate_limit_port.py create mode 100644 backend/test/unit/channel/domain/port/test_signature_verify_port.py create mode 100644 backend/test/unit/channel/domain/port/test_sse_push_port.py create mode 100644 backend/test/unit/channel/domain/port/test_ws_connection_port.py create mode 100644 backend/test/unit/channel/domain/repository/__init__.py create mode 100644 backend/test/unit/channel/domain/repository/test_binding_repository.py create mode 100644 backend/test/unit/channel/domain/repository/test_message_log_repository.py create mode 100644 backend/test/unit/channel/domain/repository/test_message_repository.py create mode 100644 backend/test/unit/channel/domain/repository/test_outbox_repository.py create mode 100644 backend/test/unit/channel/domain/repository/test_session_repository.py create mode 100644 backend/test/unit/channel/domain/service/__init__.py create mode 100644 backend/test/unit/channel/domain/service/test_message_context.py create mode 100644 backend/test/unit/channel/domain/service/test_pipeline.py create mode 100644 backend/test/unit/channel/domain/service/test_pipeline_builder.py create mode 100644 backend/test/unit/channel/infrastructure/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/agent/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/agent/test_agent_adapter.py create mode 100644 backend/test/unit/channel/infrastructure/config/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/config/test_channel_config.py create mode 100644 backend/test/unit/channel/infrastructure/config/test_redis_config_reload.py create mode 100644 backend/test/unit/channel/infrastructure/content_filter/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/content_filter/test_composite_content_filter.py create mode 100644 backend/test/unit/channel/infrastructure/content_filter/test_llm_content_filter.py create mode 100644 backend/test/unit/channel/infrastructure/content_filter/test_redis_content_filter.py create mode 100644 backend/test/unit/channel/infrastructure/matching/test_aho_corasick_matcher.py create mode 100644 backend/test/unit/channel/infrastructure/messaging/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_publisher.py create mode 100644 backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_subscriber.py create mode 100644 backend/test/unit/channel/infrastructure/messaging/test_redis_stream_queue.py create mode 100644 backend/test/unit/channel/infrastructure/metrics/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/metrics/test_prometheus_metrics.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/converter/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/converter/test_binding_converter.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/repository/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/repository/test_pg_binding_repository.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_log_repository.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_repository.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/repository/test_pg_outbox_repository.py create mode 100644 backend/test/unit/channel/infrastructure/persistence/repository/test_pg_session_repository.py create mode 100644 backend/test/unit/channel/infrastructure/security/__init__.py create mode 100644 backend/test/unit/channel/infrastructure/security/test_hmac_signature_verifier.py create mode 100644 backend/test/unit/channel/interfaces/__init__.py create mode 100644 backend/test/unit/channel/interfaces/rest/__init__.py create mode 100644 backend/test/unit/channel/interfaces/rest/auth/__init__.py create mode 100644 backend/test/unit/channel/interfaces/rest/auth/test_depends.py create mode 100644 backend/test/unit/channel/interfaces/rest/middleware/__init__.py create mode 100644 backend/test/unit/channel/interfaces/rest/middleware/test_exception_handler.py create mode 100644 backend/test/unit/channel/interfaces/rest/middleware/test_trace_id.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/__init__.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/test_bindings.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/test_channel_status.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/test_config_reload.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/test_health.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/test_metrics.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/test_registry.py create mode 100644 backend/test/unit/channel/interfaces/rest/router/test_sse.py create mode 100644 backend/test/unit/channel/interfaces/sse/__init__.py create mode 100644 backend/test/unit/channel/interfaces/sse/test_endpoint.py create mode 100644 backend/test/unit/channel/interfaces/websocket/__init__.py create mode 100644 backend/test/unit/channel/interfaces/websocket/test_manager.py create mode 100644 backend/test/unit/channel/startup/__init__.py create mode 100644 backend/test/unit/channel/startup/test_agent_adapter.py create mode 100644 backend/test/unit/channel/startup/test_aho_corasick_matcher.py create mode 100644 backend/test/unit/channel/startup/test_auth_service.py create mode 100644 backend/test/unit/channel/startup/test_binding_service.py create mode 100644 backend/test/unit/channel/startup/test_channel_binding.py create mode 100644 backend/test/unit/channel/startup/test_channel_config.py create mode 100644 backend/test/unit/channel/startup/test_channel_container.py create mode 100644 backend/test/unit/channel/startup/test_channel_registry.py create mode 100644 backend/test/unit/channel/startup/test_channel_session.py create mode 100644 backend/test/unit/channel/startup/test_composite_content_filter.py create mode 100644 backend/test/unit/channel/startup/test_config_service.py create mode 100644 backend/test/unit/channel/startup/test_container_factory.py create mode 100644 backend/test/unit/channel/startup/test_delivery_service.py create mode 100644 backend/test/unit/channel/startup/test_dingtalk_adapter.py create mode 100644 backend/test/unit/channel/startup/test_dispatch_service.py create mode 100644 backend/test/unit/channel/startup/test_domain_events.py create mode 100644 backend/test/unit/channel/startup/test_domain_exceptions.py create mode 100644 backend/test/unit/channel/startup/test_feishu_adapter.py create mode 100644 backend/test/unit/channel/startup/test_hmac_signature_verifier.py create mode 100644 backend/test/unit/channel/startup/test_hooks_adapter.py create mode 100644 backend/test/unit/channel/startup/test_inbound_service.py create mode 100644 backend/test/unit/channel/startup/test_llm_content_filter.py create mode 100644 backend/test/unit/channel/startup/test_message_context.py create mode 100644 backend/test/unit/channel/startup/test_middlewares.py create mode 100644 backend/test/unit/channel/startup/test_outbox_retry.py create mode 100644 backend/test/unit/channel/startup/test_pipeline.py create mode 100644 backend/test/unit/channel/startup/test_pipeline_builder.py create mode 100644 backend/test/unit/channel/startup/test_prometheus_metrics.py create mode 100644 backend/test/unit/channel/startup/test_redis_bot_loop_guard.py create mode 100644 backend/test/unit/channel/startup/test_redis_cache.py create mode 100644 backend/test/unit/channel/startup/test_redis_circuit_breaker.py create mode 100644 backend/test/unit/channel/startup/test_redis_config_reload.py create mode 100644 backend/test/unit/channel/startup/test_redis_content_filter.py create mode 100644 backend/test/unit/channel/startup/test_redis_pubsub.py create mode 100644 backend/test/unit/channel/startup/test_redis_rate_limiter.py create mode 100644 backend/test/unit/channel/startup/test_redis_stream_queue.py create mode 100644 backend/test/unit/channel/startup/test_session_factory.py create mode 100644 backend/test/unit/channel/startup/test_session_resolver.py create mode 100644 backend/test/unit/channel/startup/test_setup_channel.py create mode 100644 backend/test/unit/channel/startup/test_sse_endpoint.py create mode 100644 backend/test/unit/channel/startup/test_startup_module.py create mode 100644 backend/test/unit/channel/startup/test_startup_tracer.py create mode 100644 backend/test/unit/channel/startup/test_unified_message.py create mode 100644 backend/test/unit/channel/startup/test_web_adapter.py create mode 100644 backend/test/unit/channel/startup/test_worker_pool.py create mode 100644 backend/test/unit/channel/startup/test_ws_manager.py create mode 100644 backend/test/unit/channel/worker/__init__.py create mode 100644 backend/test/unit/channel/worker/test_outbox_retry.py create mode 100644 backend/test/unit/channel/worker/test_pool.py create mode 100644 backend/test/unit/channel/worker/test_session_factory.py diff --git a/backend/test/conftest.py b/backend/test/conftest.py index b0be323f..822c164a 100644 --- a/backend/test/conftest.py +++ b/backend/test/conftest.py @@ -12,6 +12,7 @@ if str(PROJECT_ROOT) not in sys.path: # Avoid package-level knowledge graph initialization during pytest collection. os.environ.setdefault("YUXI_SKIP_APP_INIT", "1") +os.environ.setdefault("OPENAI_API_KEY", "sk-test-fake") def pytest_configure(config: pytest.Config) -> None: diff --git a/backend/test/integration/api/channel/__init__.py b/backend/test/integration/api/channel/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/integration/api/channel/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/__init__.py b/backend/test/unit/channel/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/application/__init__.py b/backend/test/unit/channel/application/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/application/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/application/dto/__init__.py b/backend/test/unit/channel/application/dto/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/application/dto/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/application/dto/test_channel_dto.py b/backend/test/unit/channel/application/dto/test_channel_dto.py new file mode 100644 index 00000000..0929ef03 --- /dev/null +++ b/backend/test/unit/channel/application/dto/test_channel_dto.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from yuxi.channel.application.dto.channel_dto import ( + BindingCreateRequest, + ConfigReloadResult, + DispatchPayload, + InboundRequest, +) + + +def test_inbound_request_defaults() -> None: + req = InboundRequest(channel_type="web") + assert req.channel_type == "web" + assert req.raw_payload == {} + assert req.authorization == "" + assert req.client_ip == "" + assert req.trace_id == "" + + +def test_inbound_request_with_values() -> None: + req = InboundRequest( + channel_type="feishu", + raw_payload={"key": "value"}, + authorization="Bearer token", + client_ip="127.0.0.1", + trace_id="abc-123", + ) + assert req.channel_type == "feishu" + assert req.raw_payload == {"key": "value"} + assert req.authorization == "Bearer token" + assert req.client_ip == "127.0.0.1" + assert req.trace_id == "abc-123" + + +def test_dispatch_payload_defaults() -> None: + payload = DispatchPayload( + message_id="msg-1", + channel_type="dingtalk", + content="hello", + sender_id="user-1", + ) + assert payload.message_id == "msg-1" + assert payload.channel_type == "dingtalk" + assert payload.content == "hello" + assert payload.sender_id == "user-1" + assert payload.session_id == "" + assert payload.agent_config_id is None + assert payload.trace_id == "" + assert payload.metadata == {} + assert payload.raw_payload == {} + assert payload.attachments == [] + + +def test_dispatch_payload_with_values() -> None: + payload = DispatchPayload( + message_id="msg-2", + channel_type="web", + content="world", + sender_id="user-2", + session_id="sess-1", + agent_config_id=42, + trace_id="trace-1", + metadata={"foo": "bar"}, + raw_payload={"original": "data"}, + attachments=[{"url": "http://example.com"}], + ) + assert payload.session_id == "sess-1" + assert payload.agent_config_id == 42 + assert payload.trace_id == "trace-1" + assert payload.metadata == {"foo": "bar"} + assert payload.raw_payload == {"original": "data"} + assert payload.attachments == [{"url": "http://example.com"}] + + +def test_binding_create_request() -> None: + req = BindingCreateRequest( + channel_type="feishu", + account_id="acc-1", + group_id="grp-1", + agent_config_id=7, + ) + assert req.channel_type == "feishu" + assert req.account_id == "acc-1" + assert req.group_id == "grp-1" + assert req.agent_config_id == 7 + + +def test_config_reload_result() -> None: + result = ConfigReloadResult(updated=["auth_token"], config_hash="a1b2c3d4") + assert result.updated == ["auth_token"] + assert result.config_hash == "a1b2c3d4" + + result_none = ConfigReloadResult(updated=[], config_hash=None) + assert result_none.updated == [] + assert result_none.config_hash is None diff --git a/backend/test/unit/channel/application/pipeline/__init__.py b/backend/test/unit/channel/application/pipeline/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..360a5d0b --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_access_policy_middleware.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.pipeline.middlewares.access_policy_middleware import AccessPolicyMiddleware +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 +from yuxi.channel.domain.service.message_context import MessageContext + + +def _ctx( + *, + channel_type: str = "web", + sender_id: str = "user-1", + is_group: bool = False, + group_id: str = "", +) -> MessageContext: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.WEB, + sender=Peer(id=sender_id, name="User"), + content="hello", + metadata={"is_group": is_group, "group_id": group_id}, + ) + return MessageContext(message=msg, channel_type=channel_type) + + +async def _call_next(ctx: MessageContext) -> MessageContext: + return ctx + + +@pytest.mark.asyncio +async def test_dm_open_policy() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "open"}}) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_dm_disabled_policy() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "disabled"}}) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "DM_DISABLED" + + +@pytest.mark.asyncio +async def test_dm_allowlist_allowed() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "allowlist", "allow_from": ["user-1"]}}) + ctx = _ctx(sender_id="user-1") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_dm_allowlist_blocked() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "allowlist", "allow_from": ["user-2"]}}) + ctx = _ctx(sender_id="user-1") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "DM_NOT_ALLOWED" + + +@pytest.mark.asyncio +async def test_dm_allowlist_wildcard() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "allowlist", "allow_from": ["*"]}}) + ctx = _ctx(sender_id="anyone") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_dm_pairing_fallback_to_allowlist() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "pairing", "allow_from": ["user-1"]}}) + ctx = _ctx(sender_id="user-1") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_dm_pairing_fallback_blocked() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "pairing", "allow_from": ["user-2"]}}) + ctx = _ctx(sender_id="user-1") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "DM_NOT_ALLOWED" + + +@pytest.mark.asyncio +async def test_group_open_policy() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"group_policy": "open"}}) + ctx = _ctx(is_group=True, group_id="grp-1") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_group_disabled_policy() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"group_policy": "disabled"}}) + ctx = _ctx(is_group=True, group_id="grp-1") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "GROUP_DISABLED" + + +@pytest.mark.asyncio +async def test_group_allowlist_allowed() -> None: + 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 + + +@pytest.mark.asyncio +async def test_group_allowlist_blocked() -> None: + 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 + assert result.abort_code == "GROUP_NOT_ALLOWED" + + +@pytest.mark.asyncio +async def test_no_policy_for_channel() -> None: + mw = AccessPolicyMiddleware(policies={}) + ctx = _ctx(channel_type="web") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_already_aborted_skipped() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "disabled"}}) + ctx = _ctx() + ctx.abort("previous", "PREV") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "PREV" + + +def test_update_policies() -> None: + mw = AccessPolicyMiddleware(policies={"web": {"dm_policy": "open"}}) + mw.update_policies({"web": {"dm_policy": "disabled"}}) + assert mw._policies == {"web": {"dm_policy": "disabled"}} + + +@pytest.mark.asyncio +async def test_on_config_updated() -> None: + mw = AccessPolicyMiddleware(policies={}) + result = await mw.on_config_updated({"access_policies": {"web": {"dm_policy": "open"}}}) + assert result == ["access_policy"] + assert mw._policies == {"web": {"dm_policy": "open"}} + + +@pytest.mark.asyncio +async def test_on_config_updated_no_policies() -> None: + mw = AccessPolicyMiddleware(policies={}) + result = await mw.on_config_updated({}) + assert result == [] diff --git a/backend/test/unit/channel/application/pipeline/middlewares/test_auth_middleware.py b/backend/test/unit/channel/application/pipeline/middlewares/test_auth_middleware.py new file mode 100644 index 00000000..e3056308 --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_auth_middleware.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.pipeline.middlewares.auth_middleware import AuthMiddleware +from yuxi.channel.application.service.auth_service import AuthService +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 +from yuxi.channel.domain.service.message_context import MessageContext + + +class _FakeRateLimiter: + async def check_and_incr( + self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0 + ) -> bool: + return True + + async def is_locked(self, key: str) -> tuple[bool, int]: + return False, 0 + + async def reset(self, key: str) -> None: + pass + + +class _FakeAuthService: + def __init__(self, passed: bool = True, reason: str = "") -> None: + self._passed = passed + self._reason = reason + self.calls: list[tuple[str, dict]] = [] + + async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]: + self.calls.append((auth_header, {"client_id": client_id})) + return self._passed, self._reason + + +async def _call_next(ctx: MessageContext) -> MessageContext: + return ctx + + +def _ctx(*, auth: str = "Bearer token", client_ip: str = "127.0.0.1") -> MessageContext: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.WEB, + sender=Peer(id="user-1", name="User"), + content="hello", + raw_payload={"authorization": auth}, + metadata={"client_ip": client_ip}, + ) + return MessageContext(message=msg, channel_type="web") + + +@pytest.mark.asyncio +async def test_auth_success() -> None: + mw = AuthMiddleware(auth_service=_FakeAuthService(passed=True)) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_auth_failure() -> None: + mw = AuthMiddleware(auth_service=_FakeAuthService(passed=False, reason="bad token")) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "AUTH_FAILED" + assert "bad token" in result.abort_reason + + +@pytest.mark.asyncio +async def test_auth_rate_limited() -> None: + mw = AuthMiddleware(auth_service=_FakeAuthService(passed=False, reason="auth rate limited")) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "AUTH_RATE_LIMITED" + + +@pytest.mark.asyncio +async def test_auth_uses_client_ip() -> None: + auth = _FakeAuthService(passed=True) + mw = AuthMiddleware(auth_service=auth) + ctx = _ctx(client_ip="192.168.1.1") + await mw.process(ctx, _call_next) + assert auth.calls[0][1]["client_id"] == "192.168.1.1" + + +@pytest.mark.asyncio +async def test_auth_empty_header() -> None: + auth = _FakeAuthService(passed=False, reason="auth failed") + mw = AuthMiddleware(auth_service=auth) + ctx = _ctx(auth="") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert auth.calls[0][0] == "" + + +def test_name_property() -> None: + mw = AuthMiddleware(auth_service=_FakeAuthService()) + assert mw.name == "auth" diff --git a/backend/test/unit/channel/application/pipeline/middlewares/test_dedup_middleware.py b/backend/test/unit/channel/application/pipeline/middlewares/test_dedup_middleware.py new file mode 100644 index 00000000..a1214db7 --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_dedup_middleware.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.pipeline.middlewares.dedup_middleware import DedupMiddleware +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 +from yuxi.channel.domain.service.message_context import MessageContext + + +class _FakeCache: + def __init__(self) -> None: + self.data: dict[str, str] = {} + self.ttls: dict[str, int] = {} + + async def get(self, key: str) -> str | None: + return self.data.get(key) + + async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool = False) -> bool: + if nx and key in self.data: + return False + self.data[key] = value + if ex is not None: + self.ttls[key] = ex + return True + + async def delete(self, key: str) -> None: + self.data.pop(key, None) + + async def incr(self, key: str) -> int: + return 1 + + async def expire(self, key: str, seconds: int) -> None: + pass + + async def ttl(self, key: str) -> int: + return 0 + + async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple: + return () + + +async def _call_next(ctx: MessageContext) -> MessageContext: + return ctx + + +def _ctx(*, idempotency_key: str | None = None, message_id: str = "msg-1") -> MessageContext: + metadata: dict[str, Any] = {} + raw_payload: dict[str, Any] = {} + if idempotency_key: + metadata["idempotency_key"] = idempotency_key + msg = UnifiedMessage( + message_id=message_id, + channel_type=ChannelType.WEB, + sender=Peer(id="user-1", name="User"), + content="hello", + metadata=metadata, + raw_payload=raw_payload, + ) + return MessageContext(message=msg, channel_type="web") + + +@pytest.mark.asyncio +async def test_first_request_passes() -> None: + cache = _FakeCache() + mw = DedupMiddleware(cache) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_skipped is False + + +@pytest.mark.asyncio +async def test_duplicate_request_skipped() -> None: + cache = _FakeCache() + mw = DedupMiddleware(cache) + ctx = _ctx(message_id="msg-2") + await mw.process(ctx, _call_next) + ctx2 = _ctx(message_id="msg-2") + result = await mw.process(ctx2, _call_next) + assert result.is_skipped is True + + +@pytest.mark.asyncio +async def test_idempotency_key_used() -> None: + cache = _FakeCache() + mw = DedupMiddleware(cache) + ctx = _ctx(idempotency_key="idem-1") + await mw.process(ctx, _call_next) + ctx2 = _ctx(idempotency_key="idem-1") + result = await mw.process(ctx2, _call_next) + assert result.is_skipped is True + + +@pytest.mark.asyncio +async def test_idempotency_key_uses_custom_ttl() -> None: + cache = _FakeCache() + mw = DedupMiddleware(cache, idempotency_ttl=7200) + ctx = _ctx(idempotency_key="idem-2") + await mw.process(ctx, _call_next) + assert cache.ttls.get("channel:dedup:idem-2") == 7200 + + +@pytest.mark.asyncio +async def test_message_id_uses_default_ttl() -> None: + cache = _FakeCache() + mw = DedupMiddleware(cache) + ctx = _ctx(message_id="msg-3") + await mw.process(ctx, _call_next) + assert cache.ttls.get("channel:dedup:msg-3") == 900 + + +@pytest.mark.asyncio +async def test_already_aborted_skipped() -> None: + cache = _FakeCache() + mw = DedupMiddleware(cache) + ctx = _ctx() + ctx.abort("prev", "PREV") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + + +def test_name_property() -> None: + mw = DedupMiddleware(_FakeCache()) + assert mw.name == "dedup" 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 new file mode 100644 index 00000000..eaec6dfd --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_enqueue_mq_middleware.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.pipeline.middlewares.enqueue_mq_middleware import EnqueueMQMiddleware +from yuxi.channel.domain.model.message.attachment import Attachment +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 +from yuxi.channel.domain.service.message_context import MessageContext + + +class _FakeQueue: + def __init__(self) -> None: + self.enqueued: list[dict] = [] + + async def enqueue(self, payload: dict) -> str: + self.enqueued.append(payload) + return f"mq-{payload['message_id']}" + + async def dequeue(self, *, count: int = 1, block: int = 5000, consumer_name: str = "") -> list[dict]: + return [] + + async def ack(self, message_id: str) -> None: + pass + + async def pending_count(self) -> int: + return 0 + + +async def _call_next(ctx: MessageContext) -> MessageContext: + return ctx + + +def _ctx( + *, + message_id: str = "msg-1", + content: str = "hello", + attachments: list[Attachment] | None = None, + metadata: dict | None = None, + raw_payload: dict | None = None, +) -> MessageContext: + msg = UnifiedMessage( + message_id=message_id, + channel_type=ChannelType.WEB, + sender=Peer(id="user-1", name="User"), + content=content, + session_id="sess-1", + agent_config_id=42, + metadata=metadata or {}, + raw_payload=raw_payload or {}, + attachments=attachments or [], + ) + return MessageContext(message=msg, channel_type="web", trace_id="trace-1") + + +@pytest.mark.asyncio +async def test_enqueue_success() -> None: + queue = _FakeQueue() + mw = EnqueueMQMiddleware(queue) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + assert len(queue.enqueued) == 1 + assert queue.enqueued[0]["message_id"] == "msg-1" + assert queue.enqueued[0]["content"] == "hello" + assert queue.enqueued[0]["sender_id"] == "user-1" + assert queue.enqueued[0]["session_id"] == "sess-1" + assert queue.enqueued[0]["agent_config_id"] == 42 + assert result.message.metadata["_mq_message_id"] == "mq-msg-1" + + +@pytest.mark.asyncio +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") + ] + ctx = _ctx(attachments=attachments) + result = await mw.process(ctx, _call_next) + assert len(queue.enqueued[0]["attachments"]) == 1 + att = queue.enqueued[0]["attachments"][0] + assert att["url"] == "http://example.com/a.png" + assert att["media_type"] == "image" + assert att["filename"] == "a.png" + assert att["size_bytes"] == 1024 + assert att["mime_type"] == "image/png" + + +@pytest.mark.asyncio +async def test_enqueue_aborted_skipped() -> None: + queue = _FakeQueue() + mw = EnqueueMQMiddleware(queue) + ctx = _ctx() + ctx.abort("prev", "PREV") + result = await mw.process(ctx, _call_next) + assert len(queue.enqueued) == 0 + assert result.is_aborted is True + + +@pytest.mark.asyncio +async def test_enqueue_skipped_skipped() -> None: + queue = _FakeQueue() + mw = EnqueueMQMiddleware(queue) + ctx = _ctx() + ctx.skip() + result = await mw.process(ctx, _call_next) + assert len(queue.enqueued) == 0 + assert result.is_skipped is True + + +def test_name_property() -> None: + mw = EnqueueMQMiddleware(_FakeQueue()) + assert mw.name == "enqueue_mq" diff --git a/backend/test/unit/channel/application/pipeline/middlewares/test_keyword_filter_middleware.py b/backend/test/unit/channel/application/pipeline/middlewares/test_keyword_filter_middleware.py new file mode 100644 index 00000000..94e3ec3d --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_keyword_filter_middleware.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.pipeline.middlewares.keyword_filter_middleware import KeywordFilterMiddleware +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 +from yuxi.channel.domain.service.message_context import MessageContext + + +class _FakeKeywordMatcher: + def __init__(self, match_result: str | None = None) -> None: + self._match_result = match_result + self.keywords: set[str] = set() + + def match(self, content: str) -> str | None: + return self._match_result + + def update_keywords(self, keywords: set[str]) -> None: + self.keywords = keywords + + +async def _call_next(ctx: MessageContext) -> MessageContext: + return ctx + + +def _ctx(content: str = "hello") -> MessageContext: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.WEB, + sender=Peer(id="user-1", name="User"), + content=content, + ) + return MessageContext(message=msg, channel_type="web") + + +@pytest.mark.asyncio +async def test_no_match_passes() -> None: + mw = KeywordFilterMiddleware(_FakeKeywordMatcher(match_result=None)) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_match_aborts() -> None: + mw = KeywordFilterMiddleware(_FakeKeywordMatcher(match_result="badword")) + ctx = _ctx(content="this has badword") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "KEYWORD_BLOCKED" + assert "badword" in result.abort_reason + + +@pytest.mark.asyncio +async def test_already_aborted_skipped() -> None: + mw = KeywordFilterMiddleware(_FakeKeywordMatcher(match_result="badword")) + ctx = _ctx() + ctx.abort("prev", "PREV") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "PREV" + + +def test_update_keywords() -> None: + matcher = _FakeKeywordMatcher() + mw = KeywordFilterMiddleware(matcher) + mw.update_keywords({"foo", "bar"}) + assert matcher.keywords == {"foo", "bar"} + + +@pytest.mark.asyncio +async def test_on_config_updated_with_blocklist() -> None: + matcher = _FakeKeywordMatcher() + mw = KeywordFilterMiddleware(matcher) + result = await mw.on_config_updated({"keyword_blocklist": ["spam", "bad"]}) + assert result == ["keyword_filter"] + assert matcher.keywords == {"spam", "bad"} + + +@pytest.mark.asyncio +async def test_on_config_updated_with_set() -> None: + matcher = _FakeKeywordMatcher() + mw = KeywordFilterMiddleware(matcher) + result = await mw.on_config_updated({"keyword_blocklist": {"spam", "bad"}}) + assert result == ["keyword_filter"] + assert matcher.keywords == {"spam", "bad"} + + +@pytest.mark.asyncio +async def test_on_config_updated_no_blocklist() -> None: + matcher = _FakeKeywordMatcher() + mw = KeywordFilterMiddleware(matcher) + result = await mw.on_config_updated({}) + assert result == [] + + +def test_name_property() -> None: + mw = KeywordFilterMiddleware(_FakeKeywordMatcher()) + assert mw.name == "keyword_filter" diff --git a/backend/test/unit/channel/application/pipeline/middlewares/test_mention_gate_middleware.py b/backend/test/unit/channel/application/pipeline/middlewares/test_mention_gate_middleware.py new file mode 100644 index 00000000..c163f738 --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_mention_gate_middleware.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.pipeline.middlewares.mention_gate_middleware import MentionGateMiddleware +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 +from yuxi.channel.domain.service.message_context import MessageContext + + +async def _call_next(ctx: MessageContext) -> MessageContext: + return ctx + + +def _ctx(*, is_group: bool = False, content: str = "hello", require_mention: bool | None = None) -> MessageContext: + metadata: dict[str, Any] = {"is_group": is_group} + if require_mention is not None: + metadata["require_mention"] = require_mention + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.WEB, + sender=Peer(id="user-1", name="User"), + content=content, + metadata=metadata, + ) + return MessageContext(message=msg, channel_type="web") + + +@pytest.mark.asyncio +async def test_dm_always_passes() -> None: + mw = MentionGateMiddleware() + ctx = _ctx(is_group=False) + result = await mw.process(ctx, _call_next) + assert result.is_skipped is False + + +@pytest.mark.asyncio +async def test_group_no_mention_required() -> None: + mw = MentionGateMiddleware(default_require_mention=False) + ctx = _ctx(is_group=True) + result = await mw.process(ctx, _call_next) + assert result.is_skipped is False + + +@pytest.mark.asyncio +async def test_group_mention_required_not_met() -> None: + mw = MentionGateMiddleware(default_require_mention=True) + ctx = _ctx(is_group=True, content="hello") + result = await mw.process(ctx, _call_next) + assert result.is_skipped is True + + +@pytest.mark.asyncio +async def test_group_mention_by_bot_id() -> None: + mw = MentionGateMiddleware(default_require_mention=True) + mw.set_bot_id("bot-123") + ctx = _ctx(is_group=True, content="hello @bot-123") + result = await mw.process(ctx, _call_next) + assert result.is_skipped is False + + +@pytest.mark.asyncio +async def test_group_mention_by_pattern() -> None: + mw = MentionGateMiddleware(default_require_mention=True, mention_patterns=[r"@assistant"]) + ctx = _ctx(is_group=True, content="hello @assistant") + result = await mw.process(ctx, _call_next) + assert result.is_skipped is False + + +@pytest.mark.asyncio +async def test_group_mention_by_pattern_not_matched() -> None: + mw = MentionGateMiddleware(default_require_mention=True, mention_patterns=[r"@assistant"]) + ctx = _ctx(is_group=True, content="hello everyone") + result = await mw.process(ctx, _call_next) + assert result.is_skipped is True + + +@pytest.mark.asyncio +async def test_group_require_mention_overridden_false() -> None: + mw = MentionGateMiddleware(default_require_mention=True) + ctx = _ctx(is_group=True, require_mention=False) + result = await mw.process(ctx, _call_next) + assert result.is_skipped is False + + +@pytest.mark.asyncio +async def test_already_aborted_skipped() -> None: + mw = MentionGateMiddleware() + ctx = _ctx() + ctx.abort("prev", "PREV") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + + +def test_set_bot_id() -> None: + mw = MentionGateMiddleware() + mw.set_bot_id("bot-456") + assert mw._bot_id == "bot-456" + + +def test_update_patterns() -> None: + mw = MentionGateMiddleware() + mw.update_patterns(require_mention=False, patterns=[r"@bot"]) + assert mw._default_require_mention is False + assert len(mw._mention_patterns) == 1 + + +@pytest.mark.asyncio +async def test_on_config_updated() -> None: + mw = MentionGateMiddleware() + result = await mw.on_config_updated( + {"mention_gate_config": {"default_require_mention": False, "mention_patterns": ["@bot"]}} + ) + assert result == ["mention_gate"] + assert mw._default_require_mention is False + assert len(mw._mention_patterns) == 1 + + +@pytest.mark.asyncio +async def test_on_config_updated_empty() -> None: + mw = MentionGateMiddleware() + result = await mw.on_config_updated({}) + assert result == [] + + +def test_name_property() -> None: + mw = MentionGateMiddleware() + assert mw.name == "mention_gate" 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 new file mode 100644 index 00000000..b5e1ad68 --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_rate_limit_middleware.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.pipeline.middlewares.rate_limit_middleware import RateLimitMiddleware +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 +from yuxi.channel.domain.service.message_context import MessageContext + + +class _FakeRateLimiter: + def __init__(self, allow: bool = True) -> None: + self._allow = allow + self.calls: list[dict] = [] + + 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} + ) + return self._allow + + async def is_locked(self, key: str) -> tuple[bool, int]: + return False, 0 + + async def reset(self, key: str) -> None: + pass + + +async def _call_next(ctx: MessageContext) -> MessageContext: + return ctx + + +def _ctx(*, sender_id: str = "user-1", client_ip: str = "127.0.0.1") -> MessageContext: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.WEB, + sender=Peer(id=sender_id, name="User"), + content="hello", + metadata={"client_ip": client_ip}, + ) + return MessageContext(message=msg, channel_type="web") + + +@pytest.mark.asyncio +async def test_rate_limit_allowed() -> None: + limiter = _FakeRateLimiter(allow=True) + mw = RateLimitMiddleware(limiter, max_requests=10, window_seconds=60) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + assert limiter.calls[0]["max_attempts"] == 10 + assert limiter.calls[0]["window_seconds"] == 60 + + +@pytest.mark.asyncio +async def test_rate_limit_blocked() -> None: + limiter = _FakeRateLimiter(allow=False) + mw = RateLimitMiddleware(limiter) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "RATE_LIMITED" + assert "rate limit exceeded" in result.abort_reason + + +@pytest.mark.asyncio +async def test_rate_limit_uses_client_ip() -> None: + limiter = _FakeRateLimiter(allow=True) + mw = RateLimitMiddleware(limiter) + ctx = _ctx(client_ip="192.168.1.1") + await mw.process(ctx, _call_next) + assert "192.168.1.1" in limiter.calls[0]["key"] + + +@pytest.mark.asyncio +async def test_rate_limit_uses_sender_id_when_no_ip() -> None: + limiter = _FakeRateLimiter(allow=True) + mw = RateLimitMiddleware(limiter) + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.WEB, + sender=Peer(id="user-42", name="User"), + content="hello", + metadata={}, + ) + ctx = MessageContext(message=msg, channel_type="web") + await mw.process(ctx, _call_next) + assert "user-42" in limiter.calls[0]["key"] + + +@pytest.mark.asyncio +async def test_already_aborted_skipped() -> None: + limiter = _FakeRateLimiter(allow=False) + mw = RateLimitMiddleware(limiter) + ctx = _ctx() + ctx.abort("prev", "PREV") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert len(limiter.calls) == 0 + + +def test_name_property() -> None: + mw = RateLimitMiddleware(_FakeRateLimiter()) + assert mw.name == "rate_limit" diff --git a/backend/test/unit/channel/application/pipeline/middlewares/test_validation_middleware.py b/backend/test/unit/channel/application/pipeline/middlewares/test_validation_middleware.py new file mode 100644 index 00000000..01728b39 --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/middlewares/test_validation_middleware.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.pipeline.middlewares.validation_middleware import ValidationMiddleware +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 +from yuxi.channel.domain.service.message_context import MessageContext + + +class _FakeSignatureVerifier: + def __init__(self, valid: bool = True) -> None: + self._valid = valid + self.calls: list[tuple[dict, str, str]] = [] + + async def verify(self, payload: dict, signature: str, timestamp: str) -> bool: + self.calls.append((payload, signature, timestamp)) + return self._valid + + +async def _call_next(ctx: MessageContext) -> MessageContext: + return ctx + + +def _ctx( + *, + message_id: str = "msg-1", + content: str = "hello", + sender_id: str = "user-1", + signature: str | None = None, + timestamp: str | None = None, + raw_payload: dict | None = None, +) -> MessageContext: + metadata: dict[str, Any] = {} + rp: dict[str, Any] = raw_payload or {} + if signature: + rp["signature"] = signature + if timestamp: + rp["timestamp"] = timestamp + msg = UnifiedMessage( + message_id=message_id, + channel_type=ChannelType.WEB, + sender=Peer(id=sender_id, name="User"), + content=content, + metadata=metadata, + raw_payload=rp, + ) + return MessageContext(message=msg, channel_type="web") + + +@pytest.mark.asyncio +async def test_valid_message_passes() -> None: + mw = ValidationMiddleware() + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_missing_message_id() -> None: + mw = ValidationMiddleware() + ctx = _ctx(message_id="") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "VALIDATION_ERROR" + assert "missing message_id" in result.abort_reason + + +@pytest.mark.asyncio +async def test_empty_content() -> None: + mw = ValidationMiddleware() + ctx = _ctx(content=" ") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "VALIDATION_ERROR" + assert "empty content" in result.abort_reason + + +@pytest.mark.asyncio +async def test_payload_too_large() -> None: + mw = ValidationMiddleware(max_body_bytes=10) + ctx = _ctx(content="this is way too large for the limit") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "PAYLOAD_TOO_LARGE" + + +@pytest.mark.asyncio +async def test_sender_not_allowed() -> None: + mw = ValidationMiddleware(allow_from={"web": ["user-2"]}) + ctx = _ctx(sender_id="user-1") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "SENDER_NOT_ALLOWED" + + +@pytest.mark.asyncio +async def test_sender_allowed() -> None: + mw = ValidationMiddleware(allow_from={"web": ["user-1"]}) + ctx = _ctx(sender_id="user-1") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_sender_wildcard_allowed() -> None: + mw = ValidationMiddleware(allow_from={"web": ["*"]}) + ctx = _ctx(sender_id="anyone") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_no_allow_from_for_channel() -> None: + mw = ValidationMiddleware(allow_from={"feishu": ["user-1"]}) + ctx = _ctx(sender_id="user-2") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + + +@pytest.mark.asyncio +async def test_signature_verification_success() -> None: + verifier = _FakeSignatureVerifier(valid=True) + mw = ValidationMiddleware(signature_verifier=verifier) + ctx = _ctx(signature="sig-1", timestamp="1234567890") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + assert len(verifier.calls) == 1 + + +@pytest.mark.asyncio +async def test_signature_verification_failure() -> None: + verifier = _FakeSignatureVerifier(valid=False) + mw = ValidationMiddleware(signature_verifier=verifier) + ctx = _ctx(signature="sig-1", timestamp="1234567890") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "SIGNATURE_ERROR" + + +@pytest.mark.asyncio +async def test_no_signature_skips_verification() -> None: + verifier = _FakeSignatureVerifier(valid=True) + mw = ValidationMiddleware(signature_verifier=verifier) + ctx = _ctx() + result = await mw.process(ctx, _call_next) + assert result.is_aborted is False + assert len(verifier.calls) == 0 + + +@pytest.mark.asyncio +async def test_already_aborted_skipped() -> None: + mw = ValidationMiddleware() + ctx = _ctx() + ctx.abort("prev", "PREV") + result = await mw.process(ctx, _call_next) + assert result.is_aborted is True + assert result.abort_code == "PREV" + + +def test_update_allow_from() -> None: + mw = ValidationMiddleware(allow_from={"web": ["user-1"]}) + mw.update_allow_from({"web": ["user-2"]}) + assert mw._allow_from == {"web": ["user-2"]} + + +def test_name_property() -> None: + mw = ValidationMiddleware() + assert mw.name == "validation" diff --git a/backend/test/unit/channel/application/pipeline/test_builder.py b/backend/test/unit/channel/application/pipeline/test_builder.py new file mode 100644 index 00000000..d8784aa6 --- /dev/null +++ b/backend/test/unit/channel/application/pipeline/test_builder.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +from typing import Any + +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 _FakeRateLimiter: + async def check_and_incr( + self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0 + ) -> bool: + return True + + async def is_locked(self, key: str) -> tuple[bool, int]: + return False, 0 + + async def reset(self, key: str) -> None: + pass + + +class _FakeCache: + async def get(self, key: str) -> str | None: + return None + + async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool = False) -> bool: + return True + + async def delete(self, key: str) -> None: + pass + + async def incr(self, key: str) -> int: + return 1 + + async def expire(self, key: str, seconds: int) -> None: + pass + + async def ttl(self, key: str) -> int: + return 0 + + async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple: + return () + + +class _FakeQueue: + async def enqueue(self, payload: dict) -> str: + return "msg-id" + + async def dequeue(self, *, count: int = 1, block: int = 5000, consumer_name: str = "") -> list[dict]: + return [] + + async def ack(self, message_id: str) -> None: + pass + + async def pending_count(self) -> int: + return 0 + + +class _FakeKeywordMatcher: + def match(self, content: str) -> str | None: + return None + + def update_keywords(self, keywords: set[str]) -> None: + pass + + +class _FakeSignatureVerifier: + async def verify(self, payload: dict, signature: str, timestamp: str) -> bool: + return True + + +class _FakeMetrics: + async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_total(self, channel_type: str) -> None: + pass + + async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None: + pass + + async def record_worker_dispatch_duration(self, channel_type: str, duration_s: float) -> None: + pass + + async def record_worker_dispatch_total(self, channel_type: str, result: str) -> None: + pass + + async def record_worker_step_duration(self, step: str, channel_type: str, duration_s: float) -> None: + pass + + async def set_outbox_pending(self, count: int) -> None: + pass + + async def record_outbox_enqueued(self, channel_type: str) -> None: + pass + + async def set_sse_connections(self, count: int) -> None: + pass + + async def record_auth_attempts(self, result: str) -> None: + pass + + async def record_bot_loop_blocked(self, channel_type: str, context: str) -> None: + pass + + async def record_access_policy_blocked(self, channel_type: str, policy: str) -> None: + pass + + async def record_mention_gate_skipped(self, channel_type: str) -> None: + pass + + async def record_hooks_received(self, match_path: str) -> None: + pass + + async def record_content_filter_blocked(self, channel_type: str) -> None: + pass + + async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None: + pass + + async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None: + pass + + +@pytest.fixture +def auth_service() -> AuthService: + return AuthService(rate_limit_port=_FakeRateLimiter(), token="token") + + +@pytest.fixture +def base_kwargs(auth_service: AuthService) -> dict: + return { + "auth_service": auth_service, + "cache_port": _FakeCache(), + "rate_limit_port": _FakeRateLimiter(), + "queue_port": _FakeQueue(), + } + + +def test_build_inbound_pipeline_returns_pipeline(base_kwargs: dict) -> None: + pipeline = build_inbound_pipeline(**base_kwargs) + assert isinstance(pipeline, Pipeline) + assert len(pipeline.middlewares) == 7 + + +def test_build_inbound_pipeline_with_bot_id(base_kwargs: dict) -> None: + pipeline = build_inbound_pipeline(**base_kwargs, bot_id="bot-123") + assert isinstance(pipeline, Pipeline) + + +def test_build_inbound_pipeline_with_custom_matcher(base_kwargs: dict) -> None: + pipeline = build_inbound_pipeline(**base_kwargs, keyword_matcher=_FakeKeywordMatcher()) + assert isinstance(pipeline, Pipeline) + + +def test_build_inbound_pipeline_with_signature_verifier(base_kwargs: dict) -> None: + pipeline = build_inbound_pipeline(**base_kwargs, signature_verifier=_FakeSignatureVerifier()) + assert isinstance(pipeline, Pipeline) + + +def test_build_inbound_pipeline_with_metrics(base_kwargs: dict) -> None: + pipeline = build_inbound_pipeline(**base_kwargs, metrics=_FakeMetrics()) + assert isinstance(pipeline, Pipeline) + + +def test_build_inbound_pipeline_with_channel_config(base_kwargs: dict) -> None: + config = { + "keyword_blocklist": ["bad"], + "allow_from": {"web": ["user-1"]}, + "access_policies": {"web": {"dm_policy": "disabled"}}, + } + pipeline = build_inbound_pipeline(**base_kwargs, channel_config=config) + assert isinstance(pipeline, Pipeline) diff --git a/backend/test/unit/channel/application/service/__init__.py b/backend/test/unit/channel/application/service/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/application/service/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/application/service/test_auth_service.py b/backend/test/unit/channel/application/service/test_auth_service.py new file mode 100644 index 00000000..8661d396 --- /dev/null +++ b/backend/test/unit/channel/application/service/test_auth_service.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import base64 +import hmac +from typing import Any + +import pytest + +from yuxi.channel.application.service.auth_service import AuthService, _safe_compare + + +class _FakeRateLimiter: + def __init__(self) -> None: + self.locks: dict[str, tuple[bool, int]] = {} + self.attempts: dict[str, dict[str, Any]] = {} + self.resets: list[str] = [] + + async def is_locked(self, key: str) -> tuple[bool, int]: + return self.locks.get(key, (False, 0)) + + async def check_and_incr( + self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0 + ) -> bool: + self.attempts[key] = { + "max_attempts": max_attempts, + "window_seconds": window_seconds, + "lockout_seconds": lockout_seconds, + } + return True + + async def reset(self, key: str) -> None: + self.resets.append(key) + + +@pytest.fixture +def fake_rate_limiter() -> _FakeRateLimiter: + return _FakeRateLimiter() + + +@pytest.fixture +def auth_service(fake_rate_limiter: _FakeRateLimiter) -> AuthService: + return AuthService( + rate_limit_port=fake_rate_limiter, + token="secret-token", + password="secret-password", + max_attempts=3, + lockout_seconds=60, + ) + + +@pytest.mark.asyncio +async def test_authenticate_no_credentials_configured(fake_rate_limiter: _FakeRateLimiter) -> None: + service = AuthService(rate_limit_port=fake_rate_limiter) + passed, reason = await service.authenticate("") + assert passed is True + assert reason == "" + + +@pytest.mark.asyncio +async def test_authenticate_bearer_token_success(auth_service: AuthService) -> None: + passed, reason = await service.authenticate("Bearer secret-token") + assert passed is True + assert reason == "" + + +@pytest.mark.asyncio +async def test_authenticate_bearer_token_failure(auth_service: AuthService) -> None: + passed, reason = await service.authenticate("Bearer wrong-token") + assert passed is False + assert reason == "auth failed" + + +@pytest.mark.asyncio +async def test_authenticate_basic_password_success(auth_service: AuthService) -> None: + encoded = base64.b64encode(b"user:secret-password").decode() + passed, reason = await service.authenticate(f"Basic {encoded}") + assert passed is True + assert reason == "" + + +@pytest.mark.asyncio +async def test_authenticate_basic_password_failure(auth_service: AuthService) -> None: + encoded = base64.b64encode(b"user:wrong-password").decode() + passed, reason = await service.authenticate(f"Basic {encoded}") + assert passed is False + assert reason == "auth failed" + + +@pytest.mark.asyncio +async def test_authenticate_invalid_basic_format(auth_service: AuthService) -> None: + encoded = base64.b64encode(b"nocolon").decode() + passed, reason = await service.authenticate(f"Basic {encoded}") + assert passed is False + assert reason == "auth failed" + + +@pytest.mark.asyncio +async def test_authenticate_malformed_basic_header(auth_service: AuthService) -> None: + passed, reason = await service.authenticate("Basic not-valid-base64!!!") + assert passed is False + assert reason == "auth failed" + + +@pytest.mark.asyncio +async def test_authenticate_no_matching_scheme(auth_service: AuthService) -> None: + passed, reason = await service.authenticate("Digest something") + assert passed is False + assert reason == "auth failed" + + +@pytest.mark.asyncio +async def test_authenticate_rate_limited_lockout( + fake_rate_limiter: _FakeRateLimiter, auth_service: AuthService +) -> None: + fake_rate_limiter.locks["channel:auth:lockout::127.0.0.1"] = (True, 120) + passed, reason = await service.authenticate("Bearer wrong", client_id="127.0.0.1") + assert passed is False + assert "retry after 120s" in reason + + +@pytest.mark.asyncio +async def test_authenticate_success_resets_attempts( + fake_rate_limiter: _FakeRateLimiter, auth_service: AuthService +) -> None: + await service.authenticate("Bearer secret-token", client_id="client-1") + assert "channel:auth:attempts:client-1" in fake_rate_limiter.resets + + +@pytest.mark.asyncio +async def test_authenticate_failure_increments_attempts( + fake_rate_limiter: _FakeRateLimiter, auth_service: AuthService +) -> None: + await service.authenticate("Bearer wrong", client_id="client-2") + assert "channel:auth:attempts:client-2" in fake_rate_limiter.attempts + record = fake_rate_limiter.attempts["channel:auth:attempts:client-2"] + assert record["max_attempts"] == 3 + assert record["window_seconds"] == 60 + assert record["lockout_seconds"] == 60 + + +def test_update_credentials() -> None: + service = AuthService(rate_limit_port=_FakeRateLimiter(), token="old") + service.update_credentials(token="new", password="pwd") + assert service._token == "new" + assert service._password == "pwd" + + +def test_safe_compare_equal() -> None: + assert _safe_compare("hello", "hello") is True + + +def test_safe_compare_not_equal() -> None: + assert _safe_compare("hello", "world") is False diff --git a/backend/test/unit/channel/application/service/test_binding_service.py b/backend/test/unit/channel/application/service/test_binding_service.py new file mode 100644 index 00000000..eb1fee43 --- /dev/null +++ b/backend/test/unit/channel/application/service/test_binding_service.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.service.binding_service import BindingService +from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding + + +class _FakeBindingRepo: + def __init__(self) -> None: + self.bindings: dict[int, ChannelBinding] = {} + self.next_id = 1 + self.deleted: list[int] = [] + + 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 + return None + + async def create_binding( + self, + *, + channel_type: str, + account_id: str, + group_id: str, + agent_config_id: int, + created_by: str | None = None, + ) -> ChannelBinding: + binding = ChannelBinding( + id=self.next_id, + channel_type=channel_type, + account_id=account_id, + group_id=group_id, + agent_config_id=agent_config_id, + created_by=created_by, + ) + self.bindings[self.next_id] = binding + self.next_id += 1 + return binding + + async def update_binding( + self, + binding_id: int, + *, + agent_config_id: int | None = None, + is_enabled: bool | None = None, + updated_by: str | None = None, + ) -> ChannelBinding | None: + b = self.bindings.get(binding_id) + if b is None: + return None + if agent_config_id is not None: + b.agent_config_id = agent_config_id + if is_enabled is not None: + b.is_enabled = is_enabled + return b + + async def delete_binding(self, binding_id: int, *, updated_by: str | None = None) -> bool: + if binding_id in self.bindings: + self.deleted.append(binding_id) + del self.bindings[binding_id] + return True + return False + + async def get_binding(self, binding_id: int) -> ChannelBinding | None: + return self.bindings.get(binding_id) + + async def list_bindings( + 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] + total = len(items) + 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: + return _FakeBindingRepo() + + +@pytest.fixture +def binding_service(fake_repo: _FakeBindingRepo) -> BindingService: + return BindingService(binding_repo=fake_repo) + + +@pytest.mark.asyncio +async def test_create_binding(binding_service: BindingService) -> None: + binding = await binding_service.create( + channel_type="feishu", + account_id="acc-1", + group_id="grp-1", + agent_config_id=5, + ) + assert binding.id == 1 + assert binding.channel_type == "feishu" + assert binding.account_id == "acc-1" + assert binding.group_id == "grp-1" + assert binding.agent_config_id == 5 + + +@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 + ) + result = await binding_service.delete(created.id) + assert result is True + assert created.id in fake_repo.deleted + + +@pytest.mark.asyncio +async def test_delete_binding_not_found(binding_service: BindingService) -> None: + result = await binding_service.delete(999) + assert result is False + + +@pytest.mark.asyncio +async def test_get_binding(binding_service: BindingService) -> None: + created = await binding_service.create( + channel_type="dingtalk", account_id="acc-3", group_id="grp-3", agent_config_id=15 + ) + fetched = await binding_service.get(created.id) + assert fetched is not None + assert fetched.id == created.id + + +@pytest.mark.asyncio +async def test_get_binding_not_found(binding_service: BindingService) -> None: + fetched = await binding_service.get(999) + assert fetched is None + + +@pytest.mark.asyncio +async def test_list_bindings(binding_service: BindingService) -> None: + await binding_service.create(channel_type="feishu", account_id="a1", group_id="g1", agent_config_id=1) + await binding_service.create(channel_type="feishu", account_id="a2", group_id="g2", agent_config_id=2) + await binding_service.create(channel_type="web", account_id="a3", group_id="g3", agent_config_id=3) + + items, total = await binding_service.list() + assert total == 3 + assert len(items) == 3 + + items, total = await binding_service.list(channel_type="feishu") + assert total == 2 + assert len(items) == 2 + assert all(b.channel_type == "feishu" for b in items) + + +@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 + ) + 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 new file mode 100644 index 00000000..10bd71c8 --- /dev/null +++ b/backend/test/unit/channel/application/service/test_config_service.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import Any + +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 +from yuxi.channel.domain.service.pipeline_builder import PipelineBuilder + + +class _FakeConfigReload: + def __init__(self, data: dict | None = None) -> None: + self._data = data or {"auth": {"token": "new-token"}} + + async def reload(self) -> dict: + return self._data + + async def watch(self, callback: Any) -> None: + pass + + async def get_current(self) -> dict: + return self._data + + +class _FakeChannelConfig: + def __init__(self) -> None: + self.updated: list[str] = [] + + async def on_config_updated(self, config: dict) -> list[str]: + self.updated = ["auth_token"] + return self.updated + + +class _FakeConfigurableMiddleware: + def __init__(self, name: str, items: list[str] | None = None) -> None: + self._name = name + self._items = items or [] + + @property + def name(self) -> str: + return self._name + + def on_config_updated(self, config: dict) -> list[str]: + return list(self._items) + + async def process(self, ctx: Any, call_next: Any) -> Any: + return await call_next(ctx) + + +@pytest.fixture +def fake_config_reload() -> _FakeConfigReload: + return _FakeConfigReload() + + +@pytest.fixture +def fake_channel_config() -> _FakeChannelConfig: + return _FakeChannelConfig() + + +@pytest.fixture +def empty_pipeline() -> Pipeline: + return PipelineBuilder().build() + + +@pytest.fixture +def pipeline_with_configurable() -> Pipeline: + return ( + PipelineBuilder() + .add(_FakeConfigurableMiddleware("mw1", ["item1"])) + .add(_FakeConfigurableMiddleware("mw2", ["item2", "item3"])) + .build() + ) + + +@pytest.mark.asyncio +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, + pipeline=empty_pipeline, + ) + updated, config_hash = await service.reload() + assert service.config == {"auth": {"token": "new-token"}} + assert isinstance(config_hash, str) + assert len(config_hash) == 8 + + +@pytest.mark.asyncio +async def test_reload_with_channel_config( + fake_config_reload: _FakeConfigReload, + fake_channel_config: _FakeChannelConfig, + empty_pipeline: Pipeline, +) -> None: + service = ConfigService( + config_data={}, + config_reload=fake_config_reload, + pipeline=empty_pipeline, + channel_config=fake_channel_config, + ) + updated, _ = await service.reload() + assert "auth_token" in updated + + +@pytest.mark.asyncio +async def test_reload_updates_configurable_middlewares( + fake_config_reload: _FakeConfigReload, + pipeline_with_configurable: Pipeline, +) -> None: + service = ConfigService( + config_data={}, + config_reload=fake_config_reload, + pipeline=pipeline_with_configurable, + ) + updated, _ = await service.reload() + assert "item1" in updated + assert "item2" in updated + assert "item3" in updated + + +@pytest.mark.asyncio +async def test_reload_no_configurable_middlewares( + fake_config_reload: _FakeConfigReload, empty_pipeline: Pipeline +) -> None: + service = ConfigService( + config_data={}, + config_reload=fake_config_reload, + pipeline=empty_pipeline, + ) + updated, _ = await service.reload() + assert updated == [] + + +def test_config_property(fake_config_reload: _FakeConfigReload, empty_pipeline: Pipeline) -> None: + service = ConfigService( + config_data={"key": "value"}, + config_reload=fake_config_reload, + pipeline=empty_pipeline, + ) + assert service.config == {"key": "value"} diff --git a/backend/test/unit/channel/application/service/test_delivery_service.py b/backend/test/unit/channel/application/service/test_delivery_service.py new file mode 100644 index 00000000..ac98a929 --- /dev/null +++ b/backend/test/unit/channel/application/service/test_delivery_service.py @@ -0,0 +1,368 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +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.message.stream_chat_request import StreamChatRequest +from yuxi.channel.domain.model.session.channel_session import ChannelSession +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +class _FakeAgentPort: + def __init__(self, response: str = "fake response", raise_error: bool = False) -> None: + self.response = response + self.raise_error = raise_error + self.calls: list[StreamChatRequest] = [] + + async def stream_chat(self, request: StreamChatRequest) -> str: + self.calls.append(request) + if self.raise_error: + raise RuntimeError("agent error") + return self.response + + async def stream_chat_iter(self, request: StreamChatRequest) -> Any: + yield self.response + + +class _FakeAdapter: + def __init__( + self, + channel_type: str = "web", + send_success: bool = True, + capabilities: ChannelCapabilities | None = None, + ) -> None: + self._channel_type = channel_type + self._send_success = send_success + self._capabilities = capabilities or ChannelCapabilities( + media=False, group=False, dm=True, streaming=False, typing=False + ) + self.sent_messages: list[tuple[str, str, dict]] = [] + self.sent_media: list[tuple[str, str, str, dict]] = [] + self.typing_calls: list[str] = [] + + @property + def capabilities(self) -> ChannelCapabilities: + return self._capabilities + + @property + def channel_type(self) -> str: + return self._channel_type + + @property + def ws_connection(self) -> Any: + return None + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + 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: + self.sent_messages.append((session_id, content, metadata)) + if self._send_success: + return SendResult(success=True) + return SendResult(success=False, error="send failed") + + 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: + self.sent_media.append((session_id, url, media_type, metadata)) + return True + + async def is_healthy(self) -> bool: + return True + + @classmethod + def get_default_config(cls) -> dict: + return {} + + +class _FakeMessageRepo: + def __init__(self) -> None: + self.saved_assistant: list[tuple[str, str]] = [] + + async def save_user_message( + self, *, thread_id: str, content: str, message_id: str | None = None, extra_metadata: dict | None = None + ) -> Any: + pass + + 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 + + +class _FakeOutboxRepo: + def __init__(self) -> None: + self.enqueued: list[dict] = [] + + async def enqueue( + self, + *, + message_id: str, + session_id: str, + channel_type: str, + content: str, + trace_id: str | None = None, + extra_metadata: dict | None = None, + ) -> Any: + self.enqueued.append( + { + "message_id": message_id, + "session_id": session_id, + "channel_type": channel_type, + "content": content, + "trace_id": trace_id, + } + ) + return Any + + async def fetch_pending(self, *, limit: int = 20) -> list: + return [] + + async def get_by_id(self, entry_id: int) -> Any: + return None + + async def mark_retrying(self, entry_id: int, *, last_error: str | None = None) -> Any: + return None + + async def mark_sent(self, entry_id: int) -> bool: + return True + + async def mark_dead(self, entry_id: int, *, last_error: str) -> bool: + return True + + 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: + return [] + + +class _FakeEventPublisher: + def __init__(self) -> None: + self.published: list[Any] = [] + + async def publish(self, event: Any) -> None: + self.published.append(event) + + +class _FakeMessageLogRepo: + def __init__(self) -> None: + self.logs: list[dict] = [] + + async def create_log(self, **kwargs: Any) -> Any: + self.logs.append(kwargs) + return Any + + async def update_worker_result(self, **kwargs: Any) -> bool: + self.logs.append(kwargs) + return True + + async def update_pipeline_result(self, **kwargs: Any) -> bool: + return True + + async def get_by_trace_id(self, trace_id: str) -> list: + return [] + + async def get_by_message_id(self, message_id: str) -> list: + return [] + + async def query_logs(self, query: Any) -> list: + return [] + + +class _FakeMetrics: + def __init__(self) -> None: + self.records: list[tuple[str, tuple]] = [] + + async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_total(self, channel_type: str) -> None: + pass + + async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None: + pass + + async def record_worker_dispatch_duration(self, channel_type: str, duration_s: float) -> None: + self.records.append(("record_worker_dispatch_duration", (channel_type, duration_s))) + + async def record_worker_dispatch_total(self, channel_type: str, result: str) -> None: + self.records.append(("record_worker_dispatch_total", (channel_type, result))) + + async def record_worker_step_duration(self, step: str, channel_type: str, duration_s: float) -> None: + pass + + async def set_outbox_pending(self, count: int) -> None: + pass + + async def record_outbox_enqueued(self, channel_type: str) -> None: + self.records.append(("record_outbox_enqueued", (channel_type,))) + + async def set_sse_connections(self, count: int) -> None: + pass + + async def record_auth_attempts(self, result: str) -> None: + pass + + async def record_bot_loop_blocked(self, channel_type: str, context: str) -> None: + pass + + async def record_access_policy_blocked(self, channel_type: str, policy: str) -> None: + pass + + async def record_mention_gate_skipped(self, channel_type: str) -> None: + pass + + async def record_hooks_received(self, match_path: str) -> None: + pass + + async def record_content_filter_blocked(self, channel_type: str) -> None: + pass + + async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None: + pass + + async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None: + pass + + +@pytest.fixture +def session() -> ChannelSession: + return ChannelSession( + id=1, + thread_id="thread-1", + user_id="user-1", + agent_id="1", + channel_type="web", + channel_session_key="key-1", + status="active", + title=None, + ) + + +@pytest.fixture +def payload() -> dict: + return { + "message_id": "msg-1", + "channel_type": "web", + "content": "hello", + "sender_id": "user-1", + "session_id": "thread-1", + "trace_id": "trace-1", + "metadata": {"deliver": True}, + "raw_payload": {}, + "attachments": [], + "agent_config_id": 1, + } + + +@pytest.fixture +def delivery_service() -> DeliveryService: + return DeliveryService( + adapters={}, + message_repo=_FakeMessageRepo(), + outbox_repo=_FakeOutboxRepo(), + event_publisher=_FakeEventPublisher(), + agent_port=_FakeAgentPort(), + message_log_repo=_FakeMessageLogRepo(), + metrics=_FakeMetrics(), + agent_timeout=1.0, + typing_interval=0.1, + ) + + +@pytest.mark.asyncio +async def test_deliver_success(delivery_service: DeliveryService, payload: dict, session: ChannelSession) -> None: + adapter = _FakeAdapter(channel_type="web", capabilities=ChannelCapabilities(typing=False, media=False)) + delivery_service._adapters = {"web": adapter} + result = await delivery_service.deliver(payload, session) + assert result.success is True + assert result.message_id == "msg-1" + assert len(adapter.sent_messages) == 1 + + +@pytest.mark.asyncio +async def test_deliver_no_adapter(delivery_service: DeliveryService, payload: dict, session: ChannelSession) -> None: + result = await delivery_service.deliver(payload, session) + assert result.success is False + assert result.error == "no adapter for web" + + +@pytest.mark.asyncio +async def test_deliver_send_failure_enqueues_outbox( + delivery_service: DeliveryService, payload: dict, session: ChannelSession +) -> None: + adapter = _FakeAdapter(channel_type="web", send_success=False) + delivery_service._adapters = {"web": adapter} + result = await delivery_service.deliver(payload, session) + assert result.success is True + outbox = delivery_service._outbox + assert isinstance(outbox, _FakeOutboxRepo) + assert len(outbox.enqueued) == 1 + + +@pytest.mark.asyncio +async def test_deliver_skip_deliver(delivery_service: DeliveryService, payload: dict, session: ChannelSession) -> None: + payload["metadata"]["deliver"] = False + result = await delivery_service.deliver(payload, session) + assert result.success is True + + +@pytest.mark.asyncio +async def test_deliver_agent_crash(delivery_service: DeliveryService, payload: dict, session: ChannelSession) -> None: + delivery_service._agent = _FakeAgentPort(raise_error=True) + result = await delivery_service.deliver(payload, session) + assert result.success is False + assert result.error == "agent_crash" + + +@pytest.mark.asyncio +async def test_deliver_with_media_attachments( + delivery_service: DeliveryService, payload: dict, session: ChannelSession +) -> None: + adapter = _FakeAdapter( + channel_type="web", + capabilities=ChannelCapabilities(media=True, typing=False), + ) + delivery_service._adapters = {"web": adapter} + payload["attachments"] = [{"url": "http://example.com/img.png", "media_type": "image"}] + result = await delivery_service.deliver(payload, session) + assert result.success is True + assert len(adapter.sent_media) == 1 + + +@pytest.mark.asyncio +async def test_deliver_with_typing_indicator( + delivery_service: DeliveryService, payload: dict, session: ChannelSession +) -> None: + adapter = _FakeAdapter( + channel_type="web", + capabilities=ChannelCapabilities(typing=True), + ) + delivery_service._adapters = {"web": adapter} + delivery_service._typing_interval = 0.01 + result = await delivery_service.deliver(payload, session) + assert result.success is True diff --git a/backend/test/unit/channel/application/service/test_dispatch_service.py b/backend/test/unit/channel/application/service/test_dispatch_service.py new file mode 100644 index 00000000..2623b3b0 --- /dev/null +++ b/backend/test/unit/channel/application/service/test_dispatch_service.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.service.delivery_service import DeliveryService +from yuxi.channel.application.service.dispatch_service import DispatchService +from yuxi.channel.application.service.session_resolver import SessionResolver +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 +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: + self._passed = passed + self._violations = violations or [] + self._masked = masked or [] + + async def check(self, content: str, *, channel_type: str = "") -> Any: + from yuxi.channel.domain.port.content_filter_port import FilterResult + + return FilterResult( + passed=self._passed, + violations=self._violations, + masked_fields=self._masked, + filtered_content=content, + ) + + +class _FakeBotLoopGuard: + def __init__(self, allow: bool = True) -> None: + self._allow = allow + + async def check(self, session_id: str, *, sender_id: str = "", is_group: bool = False) -> bool: + return self._allow + + async def reset(self, session_id: str, *, sender_id: str = "", is_group: bool = False) -> None: + pass + + +class _FakeCircuitBreaker: + def __init__(self, available: bool = True) -> None: + self._available = available + self.successes: list[int] = [] + self.failures: list[int] = [] + + async def is_available(self, agent_config_id: int) -> bool: + return self._available + + async def record_success(self, agent_config_id: int) -> None: + self.successes.append(agent_config_id) + + async def record_failure(self, agent_config_id: int) -> None: + self.failures.append(agent_config_id) + + +class _FakeSessionResolver: + def __init__(self, session: ChannelSession | None = None) -> None: + self._session = session + + async def resolve(self, payload: dict) -> ChannelSession | None: + return self._session + + +class _FakeDeliveryService: + def __init__(self, result: DispatchResult | None = None, raise_error: Exception | None = None) -> None: + self._result = result or DispatchResult(success=True, message_id="msg-1") + self._raise = raise_error + self.calls: list[tuple[dict, ChannelSession]] = [] + + async def deliver(self, payload: dict, session: ChannelSession) -> DispatchResult: + self.calls.append((payload, session)) + if self._raise: + raise self._raise + return self._result + + +class _FakeMessageLogRepo: + def __init__(self) -> None: + self.logs: list[dict] = [] + + async def create_log(self, **kwargs: Any) -> Any: + self.logs.append(kwargs) + return Any + + async def update_worker_result(self, **kwargs: Any) -> bool: + self.logs.append(kwargs) + return True + + async def update_pipeline_result(self, **kwargs: Any) -> bool: + return True + + async def get_by_trace_id(self, trace_id: str) -> list: + return [] + + async def get_by_message_id(self, message_id: str) -> list: + return [] + + async def query_logs(self, query: Any) -> list: + return [] + + +class _FakeMetrics: + def __init__(self) -> None: + self.records: list[tuple[str, tuple]] = [] + + async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_total(self, channel_type: str) -> None: + pass + + async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None: + pass + + async def record_worker_dispatch_duration(self, channel_type: str, duration_s: float) -> None: + self.records.append(("record_worker_dispatch_duration", (channel_type, duration_s))) + + async def record_worker_dispatch_total(self, channel_type: str, result: str) -> None: + self.records.append(("record_worker_dispatch_total", (channel_type, result))) + + async def record_worker_step_duration(self, step: str, channel_type: str, duration_s: float) -> None: + pass + + async def set_outbox_pending(self, count: int) -> None: + pass + + async def record_outbox_enqueued(self, channel_type: str) -> None: + pass + + async def set_sse_connections(self, count: int) -> None: + pass + + async def record_auth_attempts(self, result: str) -> None: + pass + + async def record_bot_loop_blocked(self, channel_type: str, context: str) -> None: + pass + + async def record_access_policy_blocked(self, channel_type: str, policy: str) -> None: + pass + + async def record_mention_gate_skipped(self, channel_type: str) -> None: + pass + + async def record_hooks_received(self, match_path: str) -> None: + pass + + async def record_content_filter_blocked(self, channel_type: str) -> None: + pass + + async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None: + pass + + async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None: + pass + + +@pytest.fixture +def session() -> ChannelSession: + return ChannelSession( + id=1, + thread_id="thread-1", + user_id="user-1", + agent_id="1", + channel_type="web", + channel_session_key="key-1", + status="active", + title=None, + ) + + +@pytest.fixture +def payload() -> dict: + return { + "message_id": "msg-1", + "channel_type": "web", + "content": "hello", + "sender_id": "user-1", + "session_id": "thread-1", + "trace_id": "trace-1", + "metadata": {"is_group": False}, + "agent_config_id": 1, + } + + +@pytest.fixture +def dispatch_service(session: ChannelSession) -> DispatchService: + return DispatchService( + content_filter=_FakeContentFilter(), + bot_loop_guard=_FakeBotLoopGuard(), + circuit_breaker=_FakeCircuitBreaker(), + session_resolver=_FakeSessionResolver(session=session), + delivery_service=_FakeDeliveryService(), + message_log_repo=_FakeMessageLogRepo(), + metrics=_FakeMetrics(), + ) + + +@pytest.mark.asyncio +async def test_dispatch_success(dispatch_service: DispatchService, payload: dict) -> None: + result = await dispatch_service.dispatch(payload) + assert result.success is True + assert result.message_id == "msg-1" + + +@pytest.mark.asyncio +async def test_dispatch_content_blocked(dispatch_service: DispatchService, payload: dict) -> None: + dispatch_service._content_filter = _FakeContentFilter(passed=False, violations=["bad"]) + result = await dispatch_service.dispatch(payload) + assert result.success is True + + +@pytest.mark.asyncio +async def test_dispatch_bot_loop_detected(dispatch_service: DispatchService, payload: dict) -> None: + dispatch_service._bot_loop_guard = _FakeBotLoopGuard(allow=False) + result = await dispatch_service.dispatch(payload) + assert result.success is True + + +@pytest.mark.asyncio +async def test_dispatch_session_none(dispatch_service: DispatchService, payload: dict) -> None: + dispatch_service._session_resolver = _FakeSessionResolver(session=None) + result = await dispatch_service.dispatch(payload) + assert result.success is False + assert result.error == "session_error" + + +@pytest.mark.asyncio +async def test_dispatch_circuit_open(dispatch_service: DispatchService, payload: dict) -> None: + dispatch_service._circuit_breaker = _FakeCircuitBreaker(available=False) + result = await dispatch_service.dispatch(payload) + assert result.success is False + assert result.error == "circuit_open" + + +@pytest.mark.asyncio +async def test_dispatch_agent_crash(dispatch_service: DispatchService, payload: dict) -> None: + dispatch_service._delivery = _FakeDeliveryService(raise_error=AgentCrashError("crash")) + result = await dispatch_service.dispatch(payload) + assert result.success is False + assert result.error == "agent_crash" + assert 1 in dispatch_service._circuit_breaker.failures + + +@pytest.mark.asyncio +async def test_dispatch_recoverable_error_re_raises(dispatch_service: DispatchService, payload: dict) -> None: + dispatch_service._delivery = _FakeDeliveryService(raise_error=RecoverableError("retry")) + with pytest.raises(RecoverableError): + await dispatch_service.dispatch(payload) + + +@pytest.mark.asyncio +async def test_dispatch_generic_error(dispatch_service: DispatchService, payload: dict) -> None: + dispatch_service._delivery = _FakeDeliveryService(raise_error=RuntimeError("boom")) + result = await dispatch_service.dispatch(payload) + assert result.success is False + assert "boom" in (result.error or "") + assert 1 in dispatch_service._circuit_breaker.failures + + +@pytest.mark.asyncio +async def test_dispatch_records_success_on_circuit(dispatch_service: DispatchService, payload: dict) -> None: + await dispatch_service.dispatch(payload) + assert 1 in dispatch_service._circuit_breaker.successes + + +@pytest.mark.asyncio +async def test_dispatch_no_agent_config_id_uses_session_agent_id( + dispatch_service: DispatchService, payload: dict +) -> None: + payload.pop("agent_config_id") + result = await dispatch_service.dispatch(payload) + assert result.success is True diff --git a/backend/test/unit/channel/application/service/test_inbound_service.py b/backend/test/unit/channel/application/service/test_inbound_service.py new file mode 100644 index 00000000..b3c50c84 --- /dev/null +++ b/backend/test/unit/channel/application/service/test_inbound_service.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.channel.application.service.inbound_service import InboundService +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 +from yuxi.channel.domain.service.message_context import MessageContext +from yuxi.channel.domain.service.pipeline import Pipeline +from yuxi.channel.domain.service.pipeline_builder import PipelineBuilder + + +class _FakeMiddleware: + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + async def process(self, ctx: MessageContext, call_next: Any) -> MessageContext: + return await call_next(ctx) + + +class _FakePipeline: + def __init__(self, abort: bool = False, skip: bool = False) -> None: + self._abort = abort + self._skip = skip + self.calls: list[MessageContext] = [] + + async def execute(self, ctx: MessageContext) -> MessageContext: + self.calls.append(ctx) + if self._abort: + ctx.abort("test abort", "TEST_ABORT") + if self._skip: + ctx.skip() + return ctx + + @property + def middlewares(self) -> list: + return [] + + +class _FakeEventPublisher: + def __init__(self) -> None: + self.published: list[Any] = [] + + async def publish(self, event: Any) -> None: + self.published.append(event) + + +class _FakeMessageLogRepo: + def __init__(self) -> None: + self.logs: list[dict] = [] + + async def create_log(self, **kwargs: Any) -> Any: + self.logs.append(kwargs) + return Any + + async def update_pipeline_result(self, **kwargs: Any) -> bool: + self.logs.append(kwargs) + return True + + async def update_worker_result(self, **kwargs: Any) -> bool: + return True + + async def get_by_trace_id(self, trace_id: str) -> list: + return [] + + async def get_by_message_id(self, message_id: str) -> list: + return [] + + async def query_logs(self, query: Any) -> list: + return [] + + +@pytest.fixture +def unified_message() -> UnifiedMessage: + return UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.WEB, + sender=Peer(id="user-1", name="User"), + content="hello", + metadata={"client_ip": "127.0.0.1"}, + raw_payload={"authorization": "Bearer token"}, + ) + + +@pytest.mark.asyncio +async def test_submit_passes_through(unified_message: UnifiedMessage) -> None: + pipeline = _FakePipeline() + service = InboundService(pipeline=pipeline) + result = await service.submit(unified_message, channel_type="web", trace_id="trace-1") + assert result.is_aborted is False + assert result.is_skipped is False + assert result.trace_id == "trace-1" + + +@pytest.mark.asyncio +async def test_submit_aborted(unified_message: UnifiedMessage) -> None: + pipeline = _FakePipeline(abort=True) + service = InboundService(pipeline=pipeline) + result = await service.submit(unified_message, channel_type="web") + assert result.is_aborted is True + assert result.abort_reason == "test abort" + assert result.abort_code == "TEST_ABORT" + + +@pytest.mark.asyncio +async def test_submit_skipped(unified_message: UnifiedMessage) -> None: + pipeline = _FakePipeline(skip=True) + service = InboundService(pipeline=pipeline) + result = await service.submit(unified_message, channel_type="web") + assert result.is_skipped is True + + +@pytest.mark.asyncio +async def test_submit_publishes_blocked_event(unified_message: UnifiedMessage) -> None: + pipeline = _FakePipeline(abort=True) + events = _FakeEventPublisher() + service = InboundService(pipeline=pipeline, event_publisher=events) + await service.submit(unified_message, channel_type="web") + assert len(events.published) == 1 + assert events.published[0].__class__.__name__ == "MessageBlocked" + + +@pytest.mark.asyncio +async def test_submit_publishes_received_event(unified_message: UnifiedMessage) -> None: + pipeline = _FakePipeline() + events = _FakeEventPublisher() + service = InboundService(pipeline=pipeline, event_publisher=events) + await service.submit(unified_message, channel_type="web") + assert len(events.published) == 1 + assert events.published[0].__class__.__name__ == "MessageReceived" + + +@pytest.mark.asyncio +async def test_submit_no_event_on_skip(unified_message: UnifiedMessage) -> None: + pipeline = _FakePipeline(skip=True) + events = _FakeEventPublisher() + service = InboundService(pipeline=pipeline, event_publisher=events) + await service.submit(unified_message, channel_type="web") + assert len(events.published) == 0 + + +@pytest.mark.asyncio +async def test_submit_creates_log(unified_message: UnifiedMessage) -> None: + pipeline = _FakePipeline() + logs = _FakeMessageLogRepo() + service = InboundService(pipeline=pipeline, message_log_repo=logs) + await service.submit(unified_message, channel_type="web", trace_id="trace-1") + assert len(logs.logs) >= 1 + assert logs.logs[0]["trace_id"] == "trace-1" + + +@pytest.mark.asyncio +async def test_submit_updates_pipeline_log_on_abort(unified_message: UnifiedMessage) -> None: + pipeline = _FakePipeline(abort=True) + logs = _FakeMessageLogRepo() + service = InboundService(pipeline=pipeline, message_log_repo=logs) + await service.submit(unified_message, channel_type="web") + log_types = [log.get("pipeline_result") for log in logs.logs if "pipeline_result" in log] + assert "aborted" in log_types diff --git a/backend/test/unit/channel/application/service/test_session_resolver.py b/backend/test/unit/channel/application/service/test_session_resolver.py new file mode 100644 index 00000000..5662e13b --- /dev/null +++ b/backend/test/unit/channel/application/service/test_session_resolver.py @@ -0,0 +1,362 @@ +from __future__ import annotations + +from typing import Any + +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 _FakeSessionRepo: + def __init__(self) -> None: + 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} + ) + key = f"{channel_type}:{account_id}:{agent_config_id}" + if key not in self.sessions: + self.sessions[key] = ChannelSession( + id=1, + thread_id=key, + user_id=account_id, + agent_id=str(agent_config_id), + channel_type=channel_type, + channel_session_key=account_id, + status="active", + title=None, + ) + return self.sessions[key] + + async def get_by_thread_id(self, thread_id: str) -> ChannelSession | None: + return None + + async def count_active_sessions(self, channel_type: str) -> int: + return 0 + + +class _FakeBindingRepo: + def __init__(self, binding: ChannelBinding | None = None) -> None: + self._binding = binding + self.calls: list[dict] = [] + + 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 + + async def create_binding( + self, + *, + channel_type: str, + account_id: str, + group_id: str, + agent_config_id: int, + created_by: str | None = None, + ) -> ChannelBinding: + return ChannelBinding( + id=1, + channel_type=channel_type, + account_id=account_id, + group_id=group_id, + agent_config_id=agent_config_id, + ) + + async def update_binding( + self, + binding_id: int, + *, + agent_config_id: int | None = None, + is_enabled: bool | None = None, + updated_by: str | None = None, + ) -> ChannelBinding | None: + return None + + async def delete_binding(self, binding_id: int, *, updated_by: str | None = None) -> bool: + return False + + async def get_binding(self, binding_id: int) -> ChannelBinding | None: + return None + + async def list_bindings( + self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50 + ) -> 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: + return _FakeSessionRepo() + + +@pytest.fixture +def fake_binding_repo() -> _FakeBindingRepo: + return _FakeBindingRepo() + + +@pytest.fixture +def resolver( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> SessionResolver: + return SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=1, + session_key_strategy="auto", + ) + + +@pytest.mark.asyncio +async def test_resolve_with_agent_config_id(resolver: SessionResolver) -> None: + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": {"is_group": False}, + "agent_config_id": 42, + } + session = await resolver.resolve(payload) + assert session is not None + assert session.agent_id == "42" + + +@pytest.mark.asyncio +async def test_resolve_uses_binding_agent_config_id( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> None: + binding = ChannelBinding( + id=1, + channel_type="web", + account_id="acc-1", + group_id="grp-1", + agent_config_id=99, + ) + fake_binding_repo._binding = binding + resolver = SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=1, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": {"account_id": "acc-1", "group_id": "grp-1"}, + } + session = await resolver.resolve(payload) + assert session is not None + assert session.agent_id == "99" + + +@pytest.mark.asyncio +async def test_resolve_uses_default_agent_config_id( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> None: + resolver = SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=7, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": {}, + } + session = await resolver.resolve(payload) + assert session is not None + assert session.agent_id == "7" + + +@pytest.mark.asyncio +async def test_resolve_session_key_auto_group( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> None: + resolver = SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=1, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": {"is_group": True, "group_id": "grp-1"}, + } + session = await resolver.resolve(payload) + assert session is not None + assert fake_session_repo.calls[-1]["account_id"] == "user-1:web:grp-1" + + +@pytest.mark.asyncio +async def test_resolve_session_key_auto_dm( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> None: + resolver = SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=1, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": {"is_group": False}, + } + session = await resolver.resolve(payload) + assert session is not None + assert fake_session_repo.calls[-1]["account_id"] == "user-1" + + +@pytest.mark.asyncio +async def test_resolve_session_key_main_strategy( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> None: + binding = ChannelBinding( + id=1, + channel_type="web", + account_id="acc-1", + group_id="grp-1", + agent_config_id=1, + session_key_strategy="main", + ) + fake_binding_repo._binding = binding + resolver = SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=1, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": {"account_id": "acc-1", "group_id": "grp-1"}, + } + session = await resolver.resolve(payload) + assert session is not None + assert fake_session_repo.calls[-1]["account_id"] == "user-1" + + +@pytest.mark.asyncio +async def test_resolve_session_key_channel_group_strategy( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> None: + binding = ChannelBinding( + id=1, + channel_type="web", + account_id="acc-1", + group_id="grp-1", + agent_config_id=1, + session_key_strategy="channel_group", + ) + fake_binding_repo._binding = binding + resolver = SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=1, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": {"account_id": "acc-1", "group_id": "grp-1"}, + } + session = await resolver.resolve(payload) + assert session is not None + assert fake_session_repo.calls[-1]["account_id"] == "user-1:web:grp-1" + + +@pytest.mark.asyncio +async def test_resolve_session_key_custom_strategy( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> None: + binding = ChannelBinding( + id=1, + channel_type="web", + account_id="acc-1", + group_id="grp-1", + agent_config_id=1, + session_key_strategy="custom", + ) + fake_binding_repo._binding = binding + resolver = SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=1, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": { + "account_id": "acc-1", + "group_id": "grp-1", + "session_key_prefix": "pre", + "session_key": "custom-key", + }, + } + session = await resolver.resolve(payload) + assert session is not None + assert fake_session_repo.calls[-1]["account_id"] == "pre:custom-key" + + +@pytest.mark.asyncio +async def test_resolve_session_key_custom_strategy_no_prefix( + fake_session_repo: _FakeSessionRepo, fake_binding_repo: _FakeBindingRepo +) -> None: + binding = ChannelBinding( + id=1, + channel_type="web", + account_id="acc-1", + group_id="grp-1", + agent_config_id=1, + session_key_strategy="custom", + ) + fake_binding_repo._binding = binding + resolver = SessionResolver( + session_repo=fake_session_repo, + binding_repo=fake_binding_repo, + default_agent_config_id=1, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": { + "account_id": "acc-1", + "group_id": "grp-1", + "session_key": "custom-key", + }, + } + session = await resolver.resolve(payload) + assert session is not None + assert fake_session_repo.calls[-1]["account_id"] == "custom-key" + + +@pytest.mark.asyncio +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: + raise RuntimeError("db error") + + resolver = SessionResolver( + session_repo=FailingRepo(), + binding_repo=fake_binding_repo, + default_agent_config_id=1, + ) + payload = { + "channel_type": "web", + "sender_id": "user-1", + "metadata": {}, + "message_id": "msg-1", + "trace_id": "trace-1", + } + session = await resolver.resolve(payload) + assert session is None diff --git a/backend/test/unit/channel/channels/__init__.py b/backend/test/unit/channel/channels/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/channels/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/channels/dingtalk/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/channels/feishu/__init__.py b/backend/test/unit/channel/channels/feishu/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/channels/feishu/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/channels/hooks/__init__.py b/backend/test/unit/channel/channels/hooks/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/channels/hooks/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/channels/web/__init__.py b/backend/test/unit/channel/channels/web/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/channels/web/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/container/__init__.py b/backend/test/unit/channel/container/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/container/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/container/test_container_channel_container.py b/backend/test/unit/channel/container/test_container_channel_container.py new file mode 100644 index 00000000..56113fde --- /dev/null +++ b/backend/test/unit/channel/container/test_container_channel_container.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.container import ChannelContainer + + +class TestChannelContainerShutdown: + @pytest.fixture + def container(self): + return ChannelContainer( + pipeline=MagicMock(), + 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_closes_all_resources(self, container): + container.event_publisher = AsyncMock() + container.sse_endpoint = AsyncMock() + container.pubsub_subscriber = AsyncMock() + container.worker_pool = AsyncMock() + container.outbox_worker = AsyncMock() + container.ws_manager = AsyncMock() + container.redis = AsyncMock() + + adapter = AsyncMock() + container.adapters = {"feishu": adapter} + + await container.shutdown() + + container.event_publisher.publish.assert_awaited_once() + container.sse_endpoint.broadcast_shutdown.assert_awaited_once() + container.pubsub_subscriber.stop.assert_awaited_once() + container.worker_pool.stop.assert_awaited_once() + container.outbox_worker.stop.assert_awaited_once() + container.ws_manager.stop_all.assert_awaited_once() + container.sse_endpoint.stop.assert_awaited_once() + adapter.close.assert_awaited_once() + container.redis.aclose.assert_awaited_once() + + @pytest.mark.asyncio + async def test_shutdown_gracefully_handles_missing_resources(self, container): + container.event_publisher = None + container.sse_endpoint = None + container.pubsub_subscriber = None + container.worker_pool = None + container.outbox_worker = None + container.ws_manager = None + container.redis = None + container.adapters = {} + + await container.shutdown() + + @pytest.mark.asyncio + async def test_shutdown_gracefully_handles_adapter_close_exception(self, container): + adapter = AsyncMock() + adapter.close.side_effect = RuntimeError("boom") + container.adapters = {"feishu": adapter} + + await container.shutdown() + + adapter.close.assert_awaited_once() + + @pytest.mark.asyncio + async def test_shutdown_skips_event_publish_on_exception(self, container): + container.event_publisher = AsyncMock() + container.event_publisher.publish.side_effect = RuntimeError("boom") + + await container.shutdown() + + container.event_publisher.publish.assert_awaited_once() diff --git a/backend/test/unit/channel/container/test_container_factory_apply_env_overrides.py b/backend/test/unit/channel/container/test_container_factory_apply_env_overrides.py new file mode 100644 index 00000000..b25aa9e3 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_apply_env_overrides.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.container import ChannelContainerFactory + + +class TestApplyEnvOverrides: + def test_feishu_env_overrides(self, monkeypatch): + monkeypatch.setenv("FEISHU_APP_ID", "app-123") + monkeypatch.setenv("FEISHU_APP_SECRET", "secret-456") + + config = {"app_id": "", "app_secret": ""} + overrides = ChannelContainerFactory._apply_env_overrides("feishu", config) + + assert overrides["app_id"] == "app-123" + assert overrides["app_secret"] == "secret-456" + + def test_feishu_no_env_returns_empty(self, monkeypatch): + monkeypatch.delenv("FEISHU_APP_ID", raising=False) + monkeypatch.delenv("FEISHU_APP_SECRET", raising=False) + + config = {"app_id": "", "app_secret": ""} + overrides = ChannelContainerFactory._apply_env_overrides("feishu", config) + + assert overrides == {} + + def test_dingtalk_env_overrides(self, monkeypatch): + monkeypatch.setenv("DINGTALK_CLIENT_ID", "client-123") + monkeypatch.setenv("DINGTALK_CLIENT_SECRET", "secret-456") + + config = {"client_id": "", "client_secret": ""} + overrides = ChannelContainerFactory._apply_env_overrides("dingtalk", config) + + assert overrides["client_id"] == "client-123" + assert overrides["client_secret"] == "secret-456" + + def test_dingtalk_partial_env(self, monkeypatch): + monkeypatch.setenv("DINGTALK_CLIENT_ID", "client-123") + monkeypatch.delenv("DINGTALK_CLIENT_SECRET", raising=False) + + config = {"client_id": "", "client_secret": ""} + overrides = ChannelContainerFactory._apply_env_overrides("dingtalk", config) + + assert overrides["client_id"] == "client-123" + assert "client_secret" not in overrides + + def test_unknown_channel_returns_empty(self): + config = {"key": "value"} + overrides = ChannelContainerFactory._apply_env_overrides("unknown", config) + + assert overrides == {} diff --git a/backend/test/unit/channel/container/test_container_factory_build_adapters.py b/backend/test/unit/channel/container/test_container_factory_build_adapters.py new file mode 100644 index 00000000..42986470 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_build_adapters.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yuxi.channel.container import ChannelContainerFactory, _InfraBundle +from yuxi.channel.infrastructure.config.channel_config import ChannelConfig + + +class TestBuildAdapters: + @pytest.fixture + def infra(self): + return _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(), + ) + + @pytest.fixture + def config(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("web:\n max_connections: 100\n") + return ChannelConfig(str(config_file)) + + @pytest.mark.asyncio + async def test_build_adapters_returns_adapters_and_sse(self, infra, config): + mock_adapter_cls = MagicMock() + mock_adapter = AsyncMock() + mock_adapter_cls.get_default_config.return_value = {"max_connections": 1000} + mock_adapter_cls.return_value = mock_adapter + + with patch("yuxi.channel.container.get_registered_channels", return_value={"web": mock_adapter_cls}): + adapters, sse = await ChannelContainerFactory._build_adapters(config, infra) + + assert "web" in adapters + assert sse is not None + mock_adapter.open.assert_awaited_once() + + @pytest.mark.asyncio + async def test_build_adapters_injects_sse_for_web(self, infra, config): + mock_adapter_cls = MagicMock() + mock_adapter = AsyncMock() + mock_adapter_cls.get_default_config.return_value = {"max_connections": 1000} + mock_adapter_cls.return_value = mock_adapter + + with patch("yuxi.channel.container.get_registered_channels", return_value={"web": mock_adapter_cls}): + adapters, sse = await ChannelContainerFactory._build_adapters(config, infra) + + _, kwargs = mock_adapter_cls.call_args + assert kwargs["sse_push"] is sse + + @pytest.mark.asyncio + async def test_build_adapters_injects_hook_mappings(self, infra, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("hooks:\n mappings:\n - match_path: /test\n") + config = ChannelConfig(str(config_file)) + + mock_adapter_cls = MagicMock() + mock_adapter = AsyncMock() + mock_adapter_cls.get_default_config.return_value = {"mappings": []} + mock_adapter_cls.return_value = mock_adapter + + with patch("yuxi.channel.container.get_registered_channels", return_value={"hooks": mock_adapter_cls}): + adapters, sse = await ChannelContainerFactory._build_adapters(config, infra) + + _, kwargs = mock_adapter_cls.call_args + assert "mappings" in kwargs diff --git a/backend/test/unit/channel/container/test_container_factory_build_auth_service.py b/backend/test/unit/channel/container/test_container_factory_build_auth_service.py new file mode 100644 index 00000000..18a4cc32 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_build_auth_service.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from yuxi.channel.container import ChannelContainerFactory, _InfraBundle +from yuxi.channel.application.service.auth_service import AuthService +from yuxi.channel.infrastructure.config.channel_config import ChannelConfig + + +class TestBuildAuthService: + @pytest.fixture + def infra(self): + return _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(), + ) + + @pytest.fixture + def config(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text( + "auth:\n token: test-token\n password: test-pwd\n max_attempts: 3\n lockout_seconds: 60\n" + ) + return ChannelConfig(str(config_file)) + + def test_build_auth_service_uses_config_values(self, infra, config): + auth = ChannelContainerFactory._build_auth_service(infra, config) + + assert isinstance(auth, AuthService) + assert auth._token == "test-token" + assert auth._password == "test-pwd" + assert auth._max_attempts == 3 + assert auth._lockout_seconds == 60 + + def test_build_auth_service_with_empty_config(self, infra, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("{}") + config = ChannelConfig(str(config_file)) + + auth = ChannelContainerFactory._build_auth_service(infra, config) + + assert isinstance(auth, AuthService) + assert auth._token is None + assert auth._password is None + assert auth._max_attempts == 5 + assert auth._lockout_seconds == 300 diff --git a/backend/test/unit/channel/container/test_container_factory_build_config.py b/backend/test/unit/channel/container/test_container_factory_build_config.py new file mode 100644 index 00000000..7666bffe --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_build_config.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from yuxi.channel.container import ChannelContainerFactory +from yuxi.channel.infrastructure.config.channel_config import ChannelConfig + + +class TestBuildConfig: + def test_build_config_returns_channel_config(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("auth:\n token: test-token\n") + + result = ChannelContainerFactory._build_config(str(config_file)) + + assert isinstance(result, ChannelConfig) + assert result.auth_token == "test-token" + + def test_build_config_with_missing_file_uses_defaults(self, tmp_path): + config_file = tmp_path / "nonexistent.yaml" + + result = ChannelContainerFactory._build_config(str(config_file)) + + assert isinstance(result, ChannelConfig) + assert result.auth_token is None diff --git a/backend/test/unit/channel/container/test_container_factory_build_infra.py b/backend/test/unit/channel/container/test_container_factory_build_infra.py new file mode 100644 index 00000000..1d4c0441 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_build_infra.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from yuxi.channel.container import ChannelContainerFactory, _InfraBundle +from yuxi.channel.infrastructure.config.channel_config import ChannelConfig + + +class TestBuildInfra: + @pytest.fixture + def mock_redis(self): + return MagicMock() + + @pytest.fixture + def config(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("auth:\n token: test\n") + return ChannelConfig(str(config_file)) + + def test_build_infra_returns_bundle(self, mock_redis, config): + bundle = ChannelContainerFactory._build_infra(mock_redis, config) + + assert isinstance(bundle, _InfraBundle) + assert bundle.queue_port is not None + assert bundle.event_publisher is not None + assert bundle.content_filter is not None + assert bundle.config_reload is not None + assert bundle.cache_port is not None + assert bundle.rate_limit_port is not None + assert bundle.bot_loop_guard is not None + assert bundle.circuit_breaker is not None + assert bundle.metrics is not None + assert bundle.signature_verifier is not None + + def test_build_infra_uses_webhook_secret(self, mock_redis, config, monkeypatch): + monkeypatch.setenv("WEBHOOK_SECRET", "my-secret") + + bundle = ChannelContainerFactory._build_infra(mock_redis, config) + + assert bundle.signature_verifier is not None diff --git a/backend/test/unit/channel/container/test_container_factory_build_pipeline.py b/backend/test/unit/channel/container/test_container_factory_build_pipeline.py new file mode 100644 index 00000000..29be5571 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_build_pipeline.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from yuxi.channel.container import ChannelContainerFactory, _InfraBundle +from yuxi.channel.application.service.auth_service import AuthService +from yuxi.channel.domain.service.pipeline import Pipeline +from yuxi.channel.infrastructure.config.channel_config import ChannelConfig + + +class TestBuildPipeline: + @pytest.fixture + def infra(self): + return _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(), + ) + + @pytest.fixture + def auth_service(self): + return AuthService(MagicMock(), token="test") + + @pytest.fixture + def config(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("keyword_blocklist:\n - badword\nallow_from:\n feishu: true\n") + return ChannelConfig(str(config_file)) + + def test_build_pipeline_returns_pipeline(self, infra, auth_service, config): + pipeline = ChannelContainerFactory._build_pipeline(infra, config, auth_service) + + assert isinstance(pipeline, Pipeline) + assert len(pipeline.middlewares) > 0 + + def test_build_pipeline_with_empty_config(self, infra, auth_service, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("{}") + config = ChannelConfig(str(config_file)) + + pipeline = ChannelContainerFactory._build_pipeline(infra, config, auth_service) + + assert isinstance(pipeline, Pipeline) + assert len(pipeline.middlewares) > 0 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 new file mode 100644 index 00000000..1d49a69c --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_build_workers.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from yuxi.channel.container import ChannelContainerFactory, _InfraBundle, _WorkerBundle +from yuxi.channel.domain.service.pipeline import Pipeline +from yuxi.channel.infrastructure.config.channel_config import ChannelConfig + + +class TestBuildWorkers: + @pytest.fixture + def infra(self): + return _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(), + ) + + @pytest.fixture + def config(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("{}") + return ChannelConfig(str(config_file)) + + @pytest.fixture + def pipeline(self): + return MagicMock(spec=Pipeline) + + @pytest.fixture + def redis(self): + return MagicMock() + + def test_build_workers_returns_worker_bundle(self, infra, config, pipeline, redis): + adapters = {} + + 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 + ) + + assert isinstance(workers, _WorkerBundle) + 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 + assert workers.binding_repo is not None + assert workers.session_repo is not None + assert workers.outbox_repo is not None + assert workers.message_repo is not None + assert workers.message_log_repo is not None + + def test_build_workers_uses_provided_agent_port(self, infra, config, pipeline, redis): + adapters = {} + agent_port = MagicMock() + + 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 + ) + + assert workers.dispatch_service is not None + + def test_build_workers_respects_mq_config(self, infra, config, pipeline, redis): + adapters = {} + + 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, mq_workers=2, mq_max_concurrent=10 + ) + + assert workers.worker_pool._config.num_workers == 2 + assert workers.worker_pool._config.max_concurrent == 10 diff --git a/backend/test/unit/channel/container/test_container_factory_build_ws_connections.py b/backend/test/unit/channel/container/test_container_factory_build_ws_connections.py new file mode 100644 index 00000000..c92bd3d7 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_build_ws_connections.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yuxi.channel.container import ChannelContainerFactory +from yuxi.channel.domain.service.pipeline import Pipeline +from yuxi.channel.interfaces.websocket.manager import WsConnectionManager + + +class TestBuildWsConnections: + @pytest.mark.asyncio + async def test_build_ws_connections_with_no_ws_adapters(self): + pipeline = MagicMock(spec=Pipeline) + tracer = MagicMock() + adapters = {} + + ws_manager = await ChannelContainerFactory._build_ws_connections(adapters, pipeline, tracer) + + assert isinstance(ws_manager, WsConnectionManager) + assert ws_manager.connections_status == {} + + @pytest.mark.asyncio + async def test_build_ws_connections_registers_ws_adapters(self): + pipeline = MagicMock(spec=Pipeline) + tracer = MagicMock() + + ws_conn = MagicMock() + ws_conn.channel_type = "feishu" + adapter = MagicMock() + adapter.ws_connection = ws_conn + + adapters = {"feishu": adapter} + + with patch.object(WsConnectionManager, "start_all", new_callable=AsyncMock): + ws_manager = await ChannelContainerFactory._build_ws_connections(adapters, pipeline, tracer) + + assert "feishu" in ws_manager._connections + tracer.mark.assert_called_once_with("ws_feishu", "registered") + + @pytest.mark.asyncio + async def test_build_ws_connections_skips_adapters_without_ws(self): + pipeline = MagicMock(spec=Pipeline) + tracer = MagicMock() + + adapter = MagicMock() + adapter.ws_connection = None + + adapters = {"web": adapter} + + with patch.object(WsConnectionManager, "start_all", new_callable=AsyncMock): + ws_manager = await ChannelContainerFactory._build_ws_connections(adapters, pipeline, tracer) + + assert "web" not in ws_manager._connections + tracer.mark.assert_not_called() diff --git a/backend/test/unit/channel/container/test_container_factory_create.py b/backend/test/unit/channel/container/test_container_factory_create.py new file mode 100644 index 00000000..d78318fa --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_create.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yuxi.channel.container import ChannelContainer, ChannelContainerFactory + + +class TestChannelContainerFactoryCreate: + @pytest.mark.asyncio + async def test_create_returns_channel_container(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("web:\n max_connections: 100\n") + + mock_redis = MagicMock() + mock_redis.aclose = AsyncMock() + + with patch("yuxi.channel.container.aioredis.from_url", return_value=mock_redis): + with patch.object( + ChannelContainerFactory, "_build_adapters", new_callable=AsyncMock + ) as mock_build_adapters: + mock_adapter = AsyncMock() + mock_build_adapters.return_value = ({"web": mock_adapter}, AsyncMock()) + + with patch.object( + ChannelContainerFactory, "_build_ws_connections", new_callable=AsyncMock + ) as mock_build_ws: + mock_build_ws.return_value = AsyncMock() + + with patch("yuxi.channel.container.WorkerPool.start", new_callable=AsyncMock): + with patch("yuxi.channel.container.OutboxRetryWorker.start", new_callable=AsyncMock): + with patch("yuxi.channel.container.RedisPubSubSubscriber.start", new_callable=AsyncMock): + with patch("yuxi.channel.container.pg_manager") as mock_pg: + mock_pg.get_async_session_context = MagicMock() + container = await ChannelContainerFactory.create( + redis_url="redis://localhost:6379/0", + config_yaml_path=str(config_file), + ) + + assert isinstance(container, ChannelContainer) + assert container.pipeline is not None + assert container.inbound_service is not None + assert container.startup_tracer is not None + + @pytest.mark.asyncio + async def test_create_with_custom_mq_config(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("{}") + + mock_redis = MagicMock() + mock_redis.aclose = AsyncMock() + + with patch("yuxi.channel.container.aioredis.from_url", return_value=mock_redis): + with patch.object( + ChannelContainerFactory, "_build_adapters", new_callable=AsyncMock + ) as mock_build_adapters: + mock_adapter = AsyncMock() + mock_build_adapters.return_value = ({}, AsyncMock()) + + with patch.object( + ChannelContainerFactory, "_build_ws_connections", new_callable=AsyncMock + ) as mock_build_ws: + mock_build_ws.return_value = AsyncMock() + + with patch("yuxi.channel.container.WorkerPool.start", new_callable=AsyncMock): + with patch("yuxi.channel.container.OutboxRetryWorker.start", new_callable=AsyncMock): + with patch("yuxi.channel.container.RedisPubSubSubscriber.start", new_callable=AsyncMock): + with patch("yuxi.channel.container.pg_manager") as mock_pg: + mock_pg.get_async_session_context = MagicMock() + container = await ChannelContainerFactory.create( + redis_url="redis://localhost:6379/0", + config_yaml_path=str(config_file), + mq_workers=2, + mq_max_concurrent=10, + default_agent_config_id=5, + ) + + assert container.worker_pool._config.num_workers == 2 + assert container.worker_pool._config.max_concurrent == 10 diff --git a/backend/test/unit/channel/container/test_container_factory_inject_bot_ids.py b/backend/test/unit/channel/container/test_container_factory_inject_bot_ids.py new file mode 100644 index 00000000..a80859cd --- /dev/null +++ b/backend/test/unit/channel/container/test_container_factory_inject_bot_ids.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from yuxi.channel.container import ChannelContainerFactory +from yuxi.channel.domain.service.pipeline import Pipeline +from yuxi.channel.application.pipeline.middlewares.mention_gate_middleware import MentionGateMiddleware + + +class TestInjectBotIds: + def test_inject_bot_ids_sets_bot_id_on_mention_gate(self): + mention_gate = MentionGateMiddleware() + pipeline = Pipeline([mention_gate]) + + adapter = MagicMock() + adapter.bot_id = "bot-123" + + ChannelContainerFactory._inject_bot_ids(pipeline, {"feishu": adapter}) + + assert mention_gate._bot_id == "bot-123" + + def test_inject_bot_ids_skips_when_no_mention_gate(self): + pipeline = Pipeline([]) + + adapter = MagicMock() + adapter.bot_id = "bot-123" + + ChannelContainerFactory._inject_bot_ids(pipeline, {"feishu": adapter}) + + def test_inject_bot_ids_skips_adapter_without_bot_id(self): + mention_gate = MentionGateMiddleware() + pipeline = Pipeline([mention_gate]) + + adapter = MagicMock() + del adapter.bot_id + + ChannelContainerFactory._inject_bot_ids(pipeline, {"feishu": adapter}) + + assert mention_gate._bot_id is None diff --git a/backend/test/unit/channel/container/test_container_resolve_agent_id.py b/backend/test/unit/channel/container/test_container_resolve_agent_id.py new file mode 100644 index 00000000..bddbd1d4 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_resolve_agent_id.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.container import _resolve_agent_id + + +class TestResolveAgentId: + @pytest.mark.asyncio + async def test_resolve_agent_id_returns_agent_id(self): + mock_config = MagicMock() + mock_config.agent_id = "agent-123" + + mock_repo = MagicMock() + mock_repo.get_by_id = AsyncMock(return_value=mock_config) + + mock_session = MagicMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + mock_session_factory = MagicMock() + mock_session_factory.return_value = mock_session + + with patch("yuxi.channel.container.AgentConfigRepository", return_value=mock_repo): + result = await _resolve_agent_id(mock_session_factory, 1) + + assert result == "agent-123" + + @pytest.mark.asyncio + async def test_resolve_agent_id_returns_default_when_not_found(self): + mock_repo = MagicMock() + mock_repo.get_by_id = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=False) + + mock_session_factory = MagicMock() + mock_session_factory.return_value = mock_session + + with patch("yuxi.channel.container.AgentConfigRepository", return_value=mock_repo): + result = await _resolve_agent_id(mock_session_factory, 999) + + assert result == "chatbot" diff --git a/backend/test/unit/channel/container/test_container_setup_and_get.py b/backend/test/unit/channel/container/test_container_setup_and_get.py new file mode 100644 index 00000000..e30f4f36 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_setup_and_get.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from fastapi import FastAPI, HTTPException, Request + +from yuxi.channel.container import ChannelContainer, get_channel, setup_channel + + +class TestSetupChannel: + def test_setup_channel_sets_state_on_fastapi(self): + app = FastAPI() + container = MagicMock(spec=ChannelContainer) + + setup_channel(app, container) + + assert app.state.channel is container + + def test_setup_channel_ignores_non_fastapi(self): + app = MagicMock() + container = MagicMock(spec=ChannelContainer) + + setup_channel(app, container) + + assert not hasattr(app.state, "channel") + + +class TestGetChannel: + def test_get_channel_returns_container(self): + app = FastAPI() + container = MagicMock(spec=ChannelContainer) + app.state.channel = container + + request = MagicMock(spec=Request) + request.app = app + + result = get_channel(request) + assert result is container + + def test_get_channel_raises_when_not_initialized(self): + 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 + assert exc_info.value.detail == "channel not initialized" diff --git a/backend/test/unit/channel/container/test_container_startup.py b/backend/test/unit/channel/container/test_container_startup.py new file mode 100644 index 00000000..47872ee5 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_startup.py @@ -0,0 +1,79 @@ +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 TestInitChannel: + @pytest.mark.asyncio + async def test_init_channel_sets_global_container(self, tmp_path): + config_file = tmp_path / "channel_config.yaml" + config_file.write_text("{}") + + mock_container = MagicMock() + mock_container.shutdown = AsyncMock() + + 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), + ) + + assert container is mock_container + assert get_container() is mock_container + + await shutdown_channel() + + +class TestShutdownChannel: + @pytest.mark.asyncio + async def test_shutdown_channel_calls_shutdown(self): + mock_container = MagicMock() + mock_container.shutdown = AsyncMock() + + 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() + + mock_container.shutdown.assert_awaited_once() + assert get_container() is None + + @pytest.mark.asyncio + async def test_shutdown_channel_with_provided_container(self): + mock_container = MagicMock() + mock_container.shutdown = AsyncMock() + + await shutdown_channel(mock_container) + + mock_container.shutdown.assert_awaited_once() + + @pytest.mark.asyncio + async def test_shutdown_channel_handles_none(self): + await shutdown_channel(None) + + +class TestGetContainer: + def test_get_container_returns_none_initially(self): + assert get_container() is None + + +class TestRegisterChannelMiddleware: + def test_register_channel_middleware_sets_state(self): + mock_container = MagicMock() + + with patch("yuxi.channel.startup._container", mock_container): + app = MagicMock() + register_channel_middleware(app) + + assert app.state.channel is mock_container + + def test_register_channel_middleware_skips_when_no_container(self): + with patch("yuxi.channel.startup._container", None): + app = MagicMock() + register_channel_middleware(app) + + assert not hasattr(app.state, "channel") diff --git a/backend/test/unit/channel/container/test_container_startup_tracer.py b/backend/test/unit/channel/container/test_container_startup_tracer.py new file mode 100644 index 00000000..a660a546 --- /dev/null +++ b/backend/test/unit/channel/container/test_container_startup_tracer.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import time + +import pytest + +from yuxi.channel.container import StartupTracer + + +class TestStartupTracer: + def test_begin_starts_phase_and_timer(self): + tracer = StartupTracer() + tracer.begin("config") + assert tracer._current_phase == "config" + assert tracer._start_time > 0 + + def test_end_records_event_with_duration(self): + tracer = StartupTracer() + tracer.begin("config") + time.sleep(0.01) + 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"] >= 10 + + def test_mark_records_custom_event(self): + tracer = StartupTracer() + tracer.mark("ws_feishu", "registered", duration_ms=5.0) + + assert len(tracer._events) == 1 + assert tracer._events[0]["name"] == "ws_feishu" + assert tracer._events[0]["status"] == "registered" + assert tracer._events[0]["duration_ms"] == 5.0 + + def test_total_ms_sums_all_durations(self): + tracer = StartupTracer() + tracer.mark("a", "ok", duration_ms=10.0) + tracer.mark("b", "ok", duration_ms=20.0) + + assert tracer.total_ms == 30.0 + + def test_to_dict_returns_copy(self): + tracer = StartupTracer() + tracer.mark("a", "ok", duration_ms=5.0) + result = tracer.to_dict() + + assert result == tracer._events + result.append({"name": "b", "status": "ok", "duration_ms": 1.0}) + assert len(tracer._events) == 1 + + def test_multiple_phases(self): + tracer = StartupTracer() + tracer.begin("config") + time.sleep(0.005) + tracer.end() + + tracer.begin("redis") + time.sleep(0.005) + tracer.end() + + assert len(tracer._events) == 2 + assert tracer._events[0]["name"] == "config" + assert tracer._events[1]["name"] == "redis" + assert tracer.total_ms > 0 diff --git a/backend/test/unit/channel/domain/__init__.py b/backend/test/unit/channel/domain/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/event/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/event/test_agent_error.py b/backend/test/unit/channel/domain/event/test_agent_error.py new file mode 100644 index 00000000..6ac5fa84 --- /dev/null +++ b/backend/test/unit/channel/domain/event/test_agent_error.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from yuxi.channel.domain.event.agent_error import AgentError + + +def test_agent_error_defaults() -> None: + error = AgentError( + message_id="msg-1", + channel_type="feishu", + session_id="sess-1", + error_message="something went wrong", + ) + assert error.message_id == "msg-1" + assert error.channel_type == "feishu" + assert error.session_id == "sess-1" + assert error.error_message == "something went wrong" + assert error.agent_config_id is None + assert error.trace_id == "" + assert error.metadata == {} + + +def test_agent_error_with_values() -> None: + error = AgentError( + message_id="msg-2", + channel_type="dingtalk", + session_id="sess-2", + error_message="agent crashed", + agent_config_id=42, + trace_id="trace-1", + metadata={"key": "value"}, + ) + assert error.agent_config_id == 42 + assert error.trace_id == "trace-1" + assert error.metadata == {"key": "value"} diff --git a/backend/test/unit/channel/domain/event/test_gateway_shutdown.py b/backend/test/unit/channel/domain/event/test_gateway_shutdown.py new file mode 100644 index 00000000..c1e64252 --- /dev/null +++ b/backend/test/unit/channel/domain/event/test_gateway_shutdown.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from yuxi.channel.domain.event.gateway_shutdown import GatewayShutdown + + +def test_gateway_shutdown_defaults() -> None: + event = GatewayShutdown() + assert event.reason == "gateway_shutting_down" + assert event.metadata == {} + + +def test_gateway_shutdown_with_values() -> None: + event = GatewayShutdown( + reason="manual_restart", + metadata={"version": "1.0.0"}, + ) + assert event.reason == "manual_restart" + assert event.metadata == {"version": "1.0.0"} diff --git a/backend/test/unit/channel/domain/event/test_message_blocked.py b/backend/test/unit/channel/domain/event/test_message_blocked.py new file mode 100644 index 00000000..fa2a87a0 --- /dev/null +++ b/backend/test/unit/channel/domain/event/test_message_blocked.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from yuxi.channel.domain.event.message_blocked import MessageBlocked + + +def test_message_blocked_defaults() -> None: + event = MessageBlocked( + message_id="msg-1", + channel_type="feishu", + session_id="sess-1", + reason="keyword_filter", + ) + assert event.message_id == "msg-1" + assert event.channel_type == "feishu" + assert event.session_id == "sess-1" + assert event.reason == "keyword_filter" + assert event.abort_code == "" + assert event.trace_id == "" + assert event.metadata == {} + + +def test_message_blocked_with_values() -> None: + event = MessageBlocked( + message_id="msg-2", + channel_type="web", + session_id="sess-2", + reason="rate_limited", + abort_code="RATE_LIMITED", + trace_id="trace-1", + metadata={"limit": 100}, + ) + assert event.abort_code == "RATE_LIMITED" + assert event.trace_id == "trace-1" + assert event.metadata == {"limit": 100} diff --git a/backend/test/unit/channel/domain/event/test_message_received.py b/backend/test/unit/channel/domain/event/test_message_received.py new file mode 100644 index 00000000..f8f08444 --- /dev/null +++ b/backend/test/unit/channel/domain/event/test_message_received.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from yuxi.channel.domain.event.message_received import MessageReceived + + +def test_message_received_defaults() -> None: + event = MessageReceived( + message_id="msg-1", + channel_type="feishu", + session_id="sess-1", + sender_id="user-1", + ) + assert event.message_id == "msg-1" + assert event.channel_type == "feishu" + assert event.session_id == "sess-1" + assert event.sender_id == "user-1" + assert event.content_summary == "" + assert event.trace_id == "" + assert event.metadata == {} + + +def test_message_received_with_values() -> None: + event = MessageReceived( + message_id="msg-2", + channel_type="dingtalk", + session_id="sess-2", + sender_id="user-2", + content_summary="hello world", + trace_id="trace-1", + metadata={"ip": "127.0.0.1"}, + ) + assert event.content_summary == "hello world" + assert event.trace_id == "trace-1" + assert event.metadata == {"ip": "127.0.0.1"} diff --git a/backend/test/unit/channel/domain/event/test_message_replied.py b/backend/test/unit/channel/domain/event/test_message_replied.py new file mode 100644 index 00000000..2dcb9050 --- /dev/null +++ b/backend/test/unit/channel/domain/event/test_message_replied.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from yuxi.channel.domain.event.message_replied import MessageReplied + + +def test_message_replied_defaults() -> None: + event = MessageReplied( + message_id="msg-1", + channel_type="feishu", + session_id="sess-1", + ) + assert event.message_id == "msg-1" + assert event.channel_type == "feishu" + assert event.session_id == "sess-1" + assert event.reply_content_summary == "" + assert event.trace_id == "" + assert event.metadata == {} + + +def test_message_replied_with_values() -> None: + event = MessageReplied( + message_id="msg-2", + channel_type="web", + session_id="sess-2", + reply_content_summary="reply content", + trace_id="trace-1", + metadata={"latency_ms": 150}, + ) + assert event.reply_content_summary == "reply content" + assert event.trace_id == "trace-1" + assert event.metadata == {"latency_ms": 150} diff --git a/backend/test/unit/channel/domain/exception/__init__.py b/backend/test/unit/channel/domain/exception/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/exception/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/exception/test_abort_mapping.py b/backend/test/unit/channel/domain/exception/test_abort_mapping.py new file mode 100644 index 00000000..2ce66e24 --- /dev/null +++ b/backend/test/unit/channel/domain/exception/test_abort_mapping.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP + + +def test_abort_code_mapping_keys() -> None: + assert "AUTH_FAILED" in ABORT_CODE_TO_HTTP + assert "RATE_LIMITED" in ABORT_CODE_TO_HTTP + assert "VALIDATION_ERROR" in ABORT_CODE_TO_HTTP + + +def test_abort_code_mapping_values() -> None: + assert ABORT_CODE_TO_HTTP["AUTH_FAILED"] == 401 + assert ABORT_CODE_TO_HTTP["AUTH_RATE_LIMITED"] == 429 + assert ABORT_CODE_TO_HTTP["SIGNATURE_ERROR"] == 401 + assert ABORT_CODE_TO_HTTP["VALIDATION_ERROR"] == 400 + assert ABORT_CODE_TO_HTTP["PAYLOAD_TOO_LARGE"] == 413 + assert ABORT_CODE_TO_HTTP["SENDER_NOT_ALLOWED"] == 403 + assert ABORT_CODE_TO_HTTP["KEYWORD_BLOCKED"] == 400 + assert ABORT_CODE_TO_HTTP["DM_NOT_ALLOWED"] == 403 + assert ABORT_CODE_TO_HTTP["DM_DISABLED"] == 403 + assert ABORT_CODE_TO_HTTP["GROUP_NOT_ALLOWED"] == 403 + assert ABORT_CODE_TO_HTTP["GROUP_DISABLED"] == 403 + assert ABORT_CODE_TO_HTTP["PAIRING_REQUIRED"] == 403 + assert ABORT_CODE_TO_HTTP["RATE_LIMITED"] == 429 + assert ABORT_CODE_TO_HTTP["DEDUP_SKIPPED"] == 200 + + +def test_abort_code_mapping_is_dict() -> None: + assert isinstance(ABORT_CODE_TO_HTTP, dict) + assert len(ABORT_CODE_TO_HTTP) == 14 diff --git a/backend/test/unit/channel/domain/exception/test_channel_error.py b/backend/test/unit/channel/domain/exception/test_channel_error.py new file mode 100644 index 00000000..aafe104b --- /dev/null +++ b/backend/test/unit/channel/domain/exception/test_channel_error.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.exception.channel_error import ChannelError +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 + + +def test_channel_error_is_exception() -> None: + assert issubclass(ChannelError, Exception) + + +def test_channel_error_can_be_raised() -> None: + with pytest.raises(ChannelError, match="boom"): + raise ChannelError("boom") + + +def test_agent_crash_error_is_channel_error() -> None: + assert issubclass(AgentCrashError, ChannelError) + + +def test_agent_crash_error_can_be_raised() -> None: + with pytest.raises(AgentCrashError, match="crash"): + raise AgentCrashError("crash") + + +def test_recoverable_error_is_channel_error() -> None: + assert issubclass(RecoverableError, ChannelError) + + +def test_recoverable_error_can_be_raised() -> None: + with pytest.raises(RecoverableError, match="recover"): + raise RecoverableError("recover") + + +def test_unrecoverable_error_is_channel_error() -> None: + assert issubclass(UnrecoverableError, ChannelError) + + +def test_unrecoverable_error_can_be_raised() -> None: + with pytest.raises(UnrecoverableError, match="fatal"): + raise UnrecoverableError("fatal") diff --git a/backend/test/unit/channel/domain/middleware/__init__.py b/backend/test/unit/channel/domain/middleware/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/middleware/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/middleware/test_configurable.py b/backend/test/unit/channel/domain/middleware/test_configurable.py new file mode 100644 index 00000000..ac4a6863 --- /dev/null +++ b/backend/test/unit/channel/domain/middleware/test_configurable.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from yuxi.channel.domain.middleware.configurable import Configurable + + +def test_configurable_is_protocol() -> None: + assert hasattr(Configurable, "on_config_updated") + + +class _FakeConfigurable: + def on_config_updated(self, config: dict) -> list[str]: + return ["updated"] + + +def test_fake_configurable_is_instance() -> None: + obj = _FakeConfigurable() + assert isinstance(obj, Configurable) diff --git a/backend/test/unit/channel/domain/middleware/test_middleware.py b/backend/test/unit/channel/domain/middleware/test_middleware.py new file mode 100644 index 00000000..9dccb529 --- /dev/null +++ b/backend/test/unit/channel/domain/middleware/test_middleware.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from yuxi.channel.domain.middleware.middleware import Middleware, CallNext +from yuxi.channel.domain.service.message_context import MessageContext + + +def test_middleware_is_protocol() -> None: + assert hasattr(Middleware, "name") + assert hasattr(Middleware, "process") + + +class _FakeMiddleware: + @property + def name(self) -> str: + return "fake" + + async def process(self, ctx: MessageContext, call_next: CallNext) -> MessageContext: + return await call_next(ctx) + + +def test_fake_middleware_is_instance() -> None: + obj = _FakeMiddleware() + assert isinstance(obj, Middleware) diff --git a/backend/test/unit/channel/domain/model/__init__.py b/backend/test/unit/channel/domain/model/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/model/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/model/binding/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/binding/test_channel_binding.py b/backend/test/unit/channel/domain/model/binding/test_channel_binding.py new file mode 100644 index 00000000..9c7b1648 --- /dev/null +++ b/backend/test/unit/channel/domain/model/binding/test_channel_binding.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding + + +def test_channel_binding_defaults() -> None: + binding = ChannelBinding( + id=1, + channel_type="feishu", + account_id="acc-1", + group_id="grp-1", + agent_config_id=5, + ) + assert binding.id == 1 + assert binding.channel_type == "feishu" + assert binding.account_id == "acc-1" + assert binding.group_id == "grp-1" + assert binding.agent_config_id == 5 + assert binding.session_key_strategy == "auto" + assert binding.is_enabled is True + assert binding.created_by is None + assert binding.updated_by is None + assert binding.created_at == "" + assert binding.updated_at == "" + + +def test_channel_binding_with_values() -> None: + binding = ChannelBinding( + id=2, + channel_type="web", + account_id="acc-2", + group_id="grp-2", + agent_config_id=10, + session_key_strategy="main", + is_enabled=False, + created_by="admin", + updated_by="admin", + created_at="2024-01-01", + updated_at="2024-01-02", + ) + assert binding.session_key_strategy == "main" + assert binding.is_enabled is False + assert binding.created_by == "admin" + + +def test_resolve_session_key_strategy_auto_with_group() -> None: + binding = ChannelBinding( + id=1, + channel_type="feishu", + account_id="acc-1", + group_id="grp-1", + agent_config_id=5, + session_key_strategy="auto", + ) + assert binding.resolve_session_key_strategy() == "channel_group" + + +def test_resolve_session_key_strategy_auto_without_group() -> None: + binding = ChannelBinding( + id=1, + channel_type="feishu", + account_id="acc-1", + group_id="", + agent_config_id=5, + session_key_strategy="auto", + ) + assert binding.resolve_session_key_strategy() == "main" + + +def test_resolve_session_key_strategy_explicit() -> None: + binding = ChannelBinding( + id=1, + channel_type="feishu", + account_id="acc-1", + group_id="grp-1", + agent_config_id=5, + session_key_strategy="custom", + ) + assert binding.resolve_session_key_strategy() == "custom" diff --git a/backend/test/unit/channel/domain/model/message/__init__.py b/backend/test/unit/channel/domain/model/message/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/model/message/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/message/test_attachment.py b/backend/test/unit/channel/domain/model/message/test_attachment.py new file mode 100644 index 00000000..14a9b6bf --- /dev/null +++ b/backend/test/unit/channel/domain/model/message/test_attachment.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.message.attachment import Attachment + + +def test_attachment_defaults() -> None: + att = Attachment(url="http://example.com/file.jpg", media_type="image") + assert att.url == "http://example.com/file.jpg" + assert att.media_type == "image" + assert att.filename == "" + assert att.size_bytes == 0 + assert att.mime_type == "" + + +def test_attachment_with_values() -> None: + att = Attachment( + url="http://example.com/doc.pdf", + media_type="file", + filename="doc.pdf", + size_bytes=1024, + mime_type="application/pdf", + ) + assert att.filename == "doc.pdf" + assert att.size_bytes == 1024 + assert att.mime_type == "application/pdf" diff --git a/backend/test/unit/channel/domain/model/message/test_dispatch_result.py b/backend/test/unit/channel/domain/model/message/test_dispatch_result.py new file mode 100644 index 00000000..121842a9 --- /dev/null +++ b/backend/test/unit/channel/domain/model/message/test_dispatch_result.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.message.dispatch_result import DispatchResult, SendResult + + +def test_dispatch_result_success() -> None: + result = DispatchResult(success=True, message_id="msg-1") + assert result.success is True + assert result.message_id == "msg-1" + assert result.error is None + + +def test_dispatch_result_failure() -> None: + result = DispatchResult(success=False, message_id="msg-2", error="timeout") + assert result.success is False + assert result.error == "timeout" + + +def test_send_result_success() -> None: + result = SendResult(success=True) + assert result.success is True + assert result.error == "" + + +def test_send_result_failure() -> None: + result = SendResult(success=False, error="failed") + assert result.success is False + assert result.error == "failed" + + +def test_send_result_is_frozen() -> None: + result = SendResult(success=True) + assert result.success is True diff --git a/backend/test/unit/channel/domain/model/message/test_peer.py b/backend/test/unit/channel/domain/model/message/test_peer.py new file mode 100644 index 00000000..4972d5ab --- /dev/null +++ b/backend/test/unit/channel/domain/model/message/test_peer.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.message.peer import Peer + + +def test_peer_defaults() -> None: + peer = Peer(id="user-1", name="Alice") + assert peer.id == "user-1" + assert peer.name == "Alice" + assert peer.kind == "user" + + +def test_peer_with_kind() -> None: + peer = Peer(id="bot-1", name="Bot", kind="assistant") + assert peer.kind == "assistant" diff --git a/backend/test/unit/channel/domain/model/message/test_stream_chat_request.py b/backend/test/unit/channel/domain/model/message/test_stream_chat_request.py new file mode 100644 index 00000000..2ee22ced --- /dev/null +++ b/backend/test/unit/channel/domain/model/message/test_stream_chat_request.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +def test_stream_chat_request_defaults() -> None: + req = StreamChatRequest( + message_id="msg-1", + session_id="sess-1", + agent_config_id=5, + content="hello", + channel_type=ChannelType.FEISHU, + ) + assert req.message_id == "msg-1" + assert req.session_id == "sess-1" + assert req.agent_config_id == 5 + assert req.content == "hello" + assert req.channel_type == ChannelType.FEISHU + assert req.metadata == {} + + +def test_stream_chat_request_with_metadata() -> None: + req = StreamChatRequest( + message_id="msg-2", + session_id="sess-2", + agent_config_id=10, + content="world", + channel_type=ChannelType.WEB, + metadata={"key": "value"}, + ) + assert req.metadata == {"key": "value"} diff --git a/backend/test/unit/channel/domain/model/message/test_unified_message.py b/backend/test/unit/channel/domain/model/message/test_unified_message.py new file mode 100644 index 00000000..4b6b3447 --- /dev/null +++ b/backend/test/unit/channel/domain/model/message/test_unified_message.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from yuxi.channel.domain.model.message.attachment import Attachment +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 +from yuxi.channel.domain.model.shared.message_priority import MessagePriority +from yuxi.channel.domain.model.shared.message_type import MessageType + + +def test_unified_message_defaults() -> None: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.FEISHU, + sender=Peer(id="user-1", name="Alice"), + content="hello", + ) + assert msg.message_id == "msg-1" + assert msg.channel_type == ChannelType.FEISHU + assert msg.sender.id == "user-1" + assert msg.content == "hello" + assert msg.content_type == MessageType.TEXT + assert msg.session_id is None + assert msg.agent_config_id is None + assert msg.metadata == {} + assert msg.raw_payload == {} + assert isinstance(msg.timestamp, datetime) + assert msg.priority == MessagePriority.NORMAL + assert msg.reply_to is None + assert msg.attachments == [] + + +def test_unified_message_with_values() -> None: + ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) + msg = UnifiedMessage( + message_id="msg-2", + channel_type=ChannelType.WEB, + sender=Peer(id="user-2", name="Bob"), + content="world", + content_type=MessageType.MARKDOWN, + session_id="sess-1", + agent_config_id=42, + metadata={"key": "value"}, + raw_payload={"original": "data"}, + timestamp=ts, + priority=MessagePriority.HIGH, + reply_to="msg-1", + attachments=[Attachment(url="http://example.com", media_type="image")], + ) + assert msg.content_type == MessageType.MARKDOWN + assert msg.session_id == "sess-1" + assert msg.agent_config_id == 42 + assert msg.metadata == {"key": "value"} + assert msg.raw_payload == {"original": "data"} + assert msg.timestamp == ts + assert msg.priority == MessagePriority.HIGH + assert msg.reply_to == "msg-1" + assert len(msg.attachments) == 1 diff --git a/backend/test/unit/channel/domain/model/outbox/__init__.py b/backend/test/unit/channel/domain/model/outbox/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/model/outbox/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/outbox/test_outbox_entry.py b/backend/test/unit/channel/domain/model/outbox/test_outbox_entry.py new file mode 100644 index 00000000..b802cbeb --- /dev/null +++ b/backend/test/unit/channel/domain/model/outbox/test_outbox_entry.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry + + +def test_outbox_entry_creation() -> None: + entry = OutboxEntry( + id=1, + message_id="msg-1", + session_id="sess-1", + channel_type="feishu", + content="hello", + status="pending", + retry_count=0, + max_retries=3, + next_retry_at=None, + last_error=None, + trace_id=None, + extra_metadata=None, + ) + assert entry.id == 1 + assert entry.message_id == "msg-1" + assert entry.session_id == "sess-1" + assert entry.channel_type == "feishu" + assert entry.content == "hello" + assert entry.status == "pending" + assert entry.retry_count == 0 + assert entry.max_retries == 3 + assert entry.next_retry_at is None + assert entry.last_error is None + assert entry.trace_id is None + assert entry.extra_metadata is None + + +def test_outbox_entry_with_values() -> None: + entry = OutboxEntry( + id=2, + message_id="msg-2", + session_id="sess-2", + channel_type="web", + content="world", + status="retrying", + retry_count=1, + max_retries=5, + next_retry_at="2024-01-01T12:00:00", + last_error="timeout", + trace_id="trace-1", + extra_metadata={"key": "value"}, + ) + assert entry.status == "retrying" + assert entry.retry_count == 1 + assert entry.next_retry_at == "2024-01-01T12:00:00" + assert entry.last_error == "timeout" + assert entry.trace_id == "trace-1" + assert entry.extra_metadata == {"key": "value"} diff --git a/backend/test/unit/channel/domain/model/session/__init__.py b/backend/test/unit/channel/domain/model/session/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/model/session/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/session/test_channel_session.py b/backend/test/unit/channel/domain/model/session/test_channel_session.py new file mode 100644 index 00000000..922b608f --- /dev/null +++ b/backend/test/unit/channel/domain/model/session/test_channel_session.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.session.channel_session import ChannelSession + + +def test_channel_session_creation() -> None: + session = ChannelSession( + id=1, + thread_id="thread-1", + user_id="user-1", + agent_id="agent-1", + channel_type="feishu", + channel_session_key="key-1", + status="active", + title="Test Session", + ) + assert session.id == 1 + assert session.thread_id == "thread-1" + assert session.user_id == "user-1" + assert session.agent_id == "agent-1" + assert session.channel_type == "feishu" + assert session.channel_session_key == "key-1" + assert session.status == "active" + assert session.title == "Test Session" + + +def test_channel_session_with_none_values() -> None: + session = ChannelSession( + id=2, + thread_id="thread-2", + user_id="user-2", + agent_id="agent-2", + channel_type=None, + channel_session_key=None, + status="closed", + title=None, + ) + assert session.channel_type is None + assert session.channel_session_key is None + assert session.status == "closed" + assert session.title is None diff --git a/backend/test/unit/channel/domain/model/shared/__init__.py b/backend/test/unit/channel/domain/model/shared/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/model/shared/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/model/shared/test_channel_capabilities.py b/backend/test/unit/channel/domain/model/shared/test_channel_capabilities.py new file mode 100644 index 00000000..e1ce3e76 --- /dev/null +++ b/backend/test/unit/channel/domain/model/shared/test_channel_capabilities.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + + +def test_channel_capabilities_defaults() -> None: + caps = ChannelCapabilities() + assert caps.media is False + assert caps.group is False + assert caps.dm is True + assert caps.streaming is False + assert caps.typing is False + assert caps.reaction is False + assert caps.thread is False + assert caps.max_text_length == 4096 + + +def test_channel_capabilities_custom() -> None: + caps = ChannelCapabilities( + media=True, + group=True, + dm=False, + streaming=True, + typing=True, + reaction=True, + thread=True, + max_text_length=2000, + ) + assert caps.media is True + assert caps.group is True + assert caps.dm is False + assert caps.streaming is True + assert caps.typing is True + assert caps.reaction is True + assert caps.thread is True + assert caps.max_text_length == 2000 + + +def test_channel_capabilities_is_frozen() -> None: + caps = ChannelCapabilities() + assert caps.dm is True diff --git a/backend/test/unit/channel/domain/model/shared/test_channel_type.py b/backend/test/unit/channel/domain/model/shared/test_channel_type.py new file mode 100644 index 00000000..3c0b86ef --- /dev/null +++ b/backend/test/unit/channel/domain/model/shared/test_channel_type.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +def test_channel_type_values() -> None: + assert ChannelType.FEISHU == "feishu" + assert ChannelType.DINGTALK == "dingtalk" + assert ChannelType.WEB == "web" + assert ChannelType.HOOKS == "hooks" + + +def test_channel_type_is_strenum() -> None: + assert issubclass(ChannelType, str) diff --git a/backend/test/unit/channel/domain/model/shared/test_content_chunker.py b/backend/test/unit/channel/domain/model/shared/test_content_chunker.py new file mode 100644 index 00000000..5d663ac7 --- /dev/null +++ b/backend/test/unit/channel/domain/model/shared/test_content_chunker.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.shared.content_chunker import chunk_content + + +def test_chunk_content_short() -> None: + result = chunk_content("hello", max_length=10) + assert result == ["hello"] + + +def test_chunk_content_exact_length() -> None: + result = chunk_content("1234567890", max_length=10) + assert result == ["1234567890"] + + +def test_chunk_content_split() -> None: + result = chunk_content("1234567890abcdef", max_length=10) + assert result == ["1234567890", "abcdef"] + + +def test_chunk_content_multiple_splits() -> None: + result = chunk_content("a" * 25, max_length=10) + assert result == ["a" * 10, "a" * 10, "a" * 5] + + +def test_chunk_content_default_max_length() -> None: + result = chunk_content("x" * 4096) + assert len(result) == 1 + assert result[0] == "x" * 4096 + + +def test_chunk_content_exceeds_default() -> None: + result = chunk_content("x" * 4097) + assert len(result) == 2 + assert result[0] == "x" * 4096 + assert result[1] == "x" diff --git a/backend/test/unit/channel/domain/model/shared/test_message_priority.py b/backend/test/unit/channel/domain/model/shared/test_message_priority.py new file mode 100644 index 00000000..834686cd --- /dev/null +++ b/backend/test/unit/channel/domain/model/shared/test_message_priority.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.shared.message_priority import MessagePriority + + +def test_message_priority_values() -> None: + assert MessagePriority.NORMAL == "normal" + assert MessagePriority.HIGH == "high" + assert MessagePriority.LOW == "low" + + +def test_message_priority_is_strenum() -> None: + assert issubclass(MessagePriority, str) diff --git a/backend/test/unit/channel/domain/model/shared/test_message_type.py b/backend/test/unit/channel/domain/model/shared/test_message_type.py new file mode 100644 index 00000000..27573737 --- /dev/null +++ b/backend/test/unit/channel/domain/model/shared/test_message_type.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from yuxi.channel.domain.model.shared.message_type import MessageType + + +def test_message_type_values() -> None: + assert MessageType.TEXT == "text" + assert MessageType.IMAGE == "image" + assert MessageType.FILE == "file" + assert MessageType.MARKDOWN == "markdown" + + +def test_message_type_is_strenum() -> None: + assert issubclass(MessageType, str) diff --git a/backend/test/unit/channel/domain/port/__init__.py b/backend/test/unit/channel/domain/port/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/port/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/port/test_agent_port.py b/backend/test/unit/channel/domain/port/test_agent_port.py new file mode 100644 index 00000000..d2bc726f --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_agent_port.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from collections.abc import AsyncGenerator + +from yuxi.channel.domain.port.agent_port import AgentPort +from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +def test_agent_port_is_protocol() -> None: + assert hasattr(AgentPort, "stream_chat") + assert hasattr(AgentPort, "stream_chat_iter") + + +class _FakeAgentPort: + async def stream_chat(self, request: StreamChatRequest) -> str: + return "response" + + async def stream_chat_iter(self, request: StreamChatRequest) -> AsyncGenerator[str, None]: + yield "chunk" + + +def test_fake_agent_port_is_instance() -> None: + obj = _FakeAgentPort() + assert isinstance(obj, AgentPort) diff --git a/backend/test/unit/channel/domain/port/test_bot_loop_guard_port.py b/backend/test/unit/channel/domain/port/test_bot_loop_guard_port.py new file mode 100644 index 00000000..4cc72c8b --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_bot_loop_guard_port.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort + + +def test_bot_loop_guard_port_is_protocol() -> None: + assert hasattr(BotLoopGuardPort, "check") + assert hasattr(BotLoopGuardPort, "reset") + + +class _FakeBotLoopGuard: + async def check(self, session_id: str, *, sender_id: str = "", is_group: bool = False) -> bool: + return True + + async def reset(self, session_id: str, *, sender_id: str = "", is_group: bool = False) -> None: + pass + + +def test_fake_bot_loop_guard_is_instance() -> None: + obj = _FakeBotLoopGuard() + assert isinstance(obj, BotLoopGuardPort) diff --git a/backend/test/unit/channel/domain/port/test_cache_port.py b/backend/test/unit/channel/domain/port/test_cache_port.py new file mode 100644 index 00000000..c0441eed --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_cache_port.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.cache_port import CachePort + + +def test_cache_port_is_protocol() -> None: + assert hasattr(CachePort, "get") + assert hasattr(CachePort, "set") + assert hasattr(CachePort, "delete") + assert hasattr(CachePort, "incr") + assert hasattr(CachePort, "expire") + assert hasattr(CachePort, "ttl") + assert hasattr(CachePort, "eval") + + +class _FakeCache: + async def get(self, key: str) -> str | None: + return None + + async def set(self, key: str, value: str, *, ex: int | None = None, nx: bool = False) -> bool: + return True + + async def delete(self, key: str) -> None: + pass + + async def incr(self, key: str) -> int: + return 1 + + async def expire(self, key: str, seconds: int) -> None: + pass + + async def ttl(self, key: str) -> int: + return -1 + + async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple: + return () + + +def test_fake_cache_is_instance() -> None: + obj = _FakeCache() + assert isinstance(obj, CachePort) diff --git a/backend/test/unit/channel/domain/port/test_channel_adapter_port.py b/backend/test/unit/channel/domain/port/test_channel_adapter_port.py new file mode 100644 index 00000000..241d3d5d --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_channel_adapter_port.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort +from yuxi.channel.domain.model.message.unified_message import UnifiedMessage +from yuxi.channel.domain.model.message.dispatch_result import SendResult +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities +from yuxi.channel.domain.model.message.peer import Peer +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +def test_channel_adapter_port_is_protocol() -> None: + assert hasattr(ChannelAdapterPort, "open") + assert hasattr(ChannelAdapterPort, "close") + assert hasattr(ChannelAdapterPort, "receive_message") + assert hasattr(ChannelAdapterPort, "send_message") + assert hasattr(ChannelAdapterPort, "send_typing") + assert hasattr(ChannelAdapterPort, "send_media") + assert hasattr(ChannelAdapterPort, "is_healthy") + assert hasattr(ChannelAdapterPort, "capabilities") + assert hasattr(ChannelAdapterPort, "channel_type") + assert hasattr(ChannelAdapterPort, "ws_connection") + assert hasattr(ChannelAdapterPort, "get_default_config") + + +class _FakeChannelAdapter: + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + async def receive_message(self, raw: dict) -> UnifiedMessage: + return UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.WEB, + sender=Peer(id="user-1", name="Alice"), + content="hello", + ) + + async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult: + return SendResult(success=True) + + async def send_typing(self, session_id: str) -> None: + pass + + async def send_media(self, session_id: str, *, url: str, media_type: str, metadata: dict) -> bool: + return True + + async def is_healthy(self) -> bool: + return True + + @property + def capabilities(self) -> ChannelCapabilities: + return ChannelCapabilities() + + @property + def channel_type(self) -> str: + return "web" + + @property + def ws_connection(self) -> None: + return None + + @classmethod + def get_default_config(cls) -> dict: + return {} + + +def test_fake_channel_adapter_is_instance() -> None: + obj = _FakeChannelAdapter() + assert isinstance(obj, ChannelAdapterPort) 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 new file mode 100644 index 00000000..13caf35f --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_channel_route_contributor.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor + + +def test_channel_route_contributor_is_protocol() -> None: + assert hasattr(ChannelRouteContributor, "router") + + +class _FakeContributor: + @property + def router(self) -> object: + return {} + + +def test_fake_contributor_is_instance() -> None: + obj = _FakeContributor() + assert isinstance(obj, ChannelRouteContributor) diff --git a/backend/test/unit/channel/domain/port/test_circuit_breaker_port.py b/backend/test/unit/channel/domain/port/test_circuit_breaker_port.py new file mode 100644 index 00000000..5ad2611c --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_circuit_breaker_port.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort + + +def test_circuit_breaker_port_is_protocol() -> None: + assert hasattr(CircuitBreakerPort, "is_available") + assert hasattr(CircuitBreakerPort, "record_success") + assert hasattr(CircuitBreakerPort, "record_failure") + + +class _FakeCircuitBreaker: + async def is_available(self, agent_config_id: int) -> bool: + return True + + async def record_success(self, agent_config_id: int) -> None: + pass + + async def record_failure(self, agent_config_id: int) -> None: + pass + + +def test_fake_circuit_breaker_is_instance() -> None: + obj = _FakeCircuitBreaker() + assert isinstance(obj, CircuitBreakerPort) diff --git a/backend/test/unit/channel/domain/port/test_config_reload_port.py b/backend/test/unit/channel/domain/port/test_config_reload_port.py new file mode 100644 index 00000000..7d009876 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_config_reload_port.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort + + +def test_config_reload_port_is_protocol() -> None: + assert hasattr(ConfigReloadPort, "watch") + assert hasattr(ConfigReloadPort, "reload") + assert hasattr(ConfigReloadPort, "get_current") + + +class _FakeConfigReload: + async def watch(self, callback: Callable[[dict], Awaitable[None]]) -> None: + pass + + async def reload(self) -> dict: + return {} + + async def get_current(self) -> dict: + return {} + + +def test_fake_config_reload_is_instance() -> None: + obj = _FakeConfigReload() + assert isinstance(obj, ConfigReloadPort) diff --git a/backend/test/unit/channel/domain/port/test_content_filter_port.py b/backend/test/unit/channel/domain/port/test_content_filter_port.py new file mode 100644 index 00000000..dcc72008 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_content_filter_port.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.content_filter_port import ContentFilterPort, FilterResult + + +def test_filter_result_defaults() -> None: + result = FilterResult(passed=True) + assert result.passed is True + assert result.violations == [] + assert result.masked_fields == [] + assert result.filtered_content == "" + + +def test_filter_result_with_values() -> None: + result = FilterResult( + passed=False, + violations=["bad_word"], + masked_fields=["content"], + filtered_content="***", + ) + assert result.passed is False + assert result.violations == ["bad_word"] + assert result.masked_fields == ["content"] + assert result.filtered_content == "***" + + +def test_content_filter_port_is_protocol() -> None: + assert hasattr(ContentFilterPort, "check") + + +class _FakeContentFilter: + async def check(self, content: str, *, channel_type: str = "") -> FilterResult: + return FilterResult(passed=True) + + +def test_fake_content_filter_is_instance() -> None: + obj = _FakeContentFilter() + assert isinstance(obj, ContentFilterPort) diff --git a/backend/test/unit/channel/domain/port/test_event_publisher_port.py b/backend/test/unit/channel/domain/port/test_event_publisher_port.py new file mode 100644 index 00000000..96d9dc24 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_event_publisher_port.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort, DomainEvent +from yuxi.channel.domain.event.message_received import MessageReceived + + +def test_event_publisher_port_is_protocol() -> None: + assert hasattr(EventPublisherPort, "publish") + + +class _FakeEventPublisher: + async def publish(self, event: DomainEvent) -> None: + pass + + +def test_fake_event_publisher_is_instance() -> None: + obj = _FakeEventPublisher() + assert isinstance(obj, EventPublisherPort) + + +def test_domain_event_union() -> None: + event = MessageReceived( + message_id="msg-1", + channel_type="feishu", + session_id="sess-1", + sender_id="user-1", + ) + assert isinstance(event, MessageReceived) diff --git a/backend/test/unit/channel/domain/port/test_keyword_matcher_port.py b/backend/test/unit/channel/domain/port/test_keyword_matcher_port.py new file mode 100644 index 00000000..e2e4c406 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_keyword_matcher_port.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.keyword_matcher_port import KeywordMatcherPort + + +def test_keyword_matcher_port_is_protocol() -> None: + assert hasattr(KeywordMatcherPort, "match") + assert hasattr(KeywordMatcherPort, "update_keywords") + + +class _FakeKeywordMatcher: + def match(self, content: str) -> str | None: + return None + + def update_keywords(self, keywords: set[str]) -> None: + pass + + +def test_fake_keyword_matcher_is_instance() -> None: + obj = _FakeKeywordMatcher() + assert isinstance(obj, KeywordMatcherPort) diff --git a/backend/test/unit/channel/domain/port/test_metrics_port.py b/backend/test/unit/channel/domain/port/test_metrics_port.py new file mode 100644 index 00000000..840818f1 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_metrics_port.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.metrics_port import MetricsPort + + +def test_metrics_port_is_protocol() -> None: + assert hasattr(MetricsPort, "record_middleware_duration") + assert hasattr(MetricsPort, "record_pipeline_duration") + assert hasattr(MetricsPort, "record_pipeline_total") + assert hasattr(MetricsPort, "record_pipeline_aborted") + assert hasattr(MetricsPort, "record_worker_dispatch_duration") + assert hasattr(MetricsPort, "record_worker_dispatch_total") + assert hasattr(MetricsPort, "record_worker_step_duration") + assert hasattr(MetricsPort, "set_outbox_pending") + assert hasattr(MetricsPort, "record_outbox_enqueued") + assert hasattr(MetricsPort, "set_sse_connections") + assert hasattr(MetricsPort, "record_auth_attempts") + assert hasattr(MetricsPort, "record_bot_loop_blocked") + assert hasattr(MetricsPort, "record_access_policy_blocked") + assert hasattr(MetricsPort, "record_mention_gate_skipped") + assert hasattr(MetricsPort, "record_hooks_received") + assert hasattr(MetricsPort, "record_content_filter_blocked") + assert hasattr(MetricsPort, "set_circuit_breaker_state") + assert hasattr(MetricsPort, "record_circuit_breaker_rejected") + + +class _FakeMetrics: + async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_total(self, channel_type: str) -> None: + pass + + async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None: + pass + + async def record_worker_dispatch_duration(self, channel_type: str, duration_s: float) -> None: + pass + + async def record_worker_dispatch_total(self, channel_type: str, result: str) -> None: + pass + + async def record_worker_step_duration(self, step: str, channel_type: str, duration_s: float) -> None: + pass + + async def set_outbox_pending(self, count: int) -> None: + pass + + async def record_outbox_enqueued(self, channel_type: str) -> None: + pass + + async def set_sse_connections(self, count: int) -> None: + pass + + async def record_auth_attempts(self, result: str) -> None: + pass + + async def record_bot_loop_blocked(self, channel_type: str, context: str) -> None: + pass + + async def record_access_policy_blocked(self, channel_type: str, policy: str) -> None: + pass + + async def record_mention_gate_skipped(self, channel_type: str) -> None: + pass + + async def record_hooks_received(self, match_path: str) -> None: + pass + + async def record_content_filter_blocked(self, channel_type: str) -> None: + pass + + async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None: + pass + + async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None: + pass + + +def test_fake_metrics_is_instance() -> None: + obj = _FakeMetrics() + assert isinstance(obj, MetricsPort) diff --git a/backend/test/unit/channel/domain/port/test_queue_port.py b/backend/test/unit/channel/domain/port/test_queue_port.py new file mode 100644 index 00000000..6b61dd8a --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_queue_port.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.queue_port import QueuePort + + +def test_queue_port_is_protocol() -> None: + assert hasattr(QueuePort, "enqueue") + assert hasattr(QueuePort, "dequeue") + assert hasattr(QueuePort, "ack") + assert hasattr(QueuePort, "pending_count") + + +class _FakeQueue: + async def enqueue(self, payload: dict) -> str: + return "msg-id" + + async def dequeue(self, *, count: int = 1, block: int = 5000, consumer_name: str = "") -> list[dict]: + return [] + + async def ack(self, message_id: str) -> None: + pass + + async def pending_count(self) -> int: + return 0 + + +def test_fake_queue_is_instance() -> None: + obj = _FakeQueue() + assert isinstance(obj, QueuePort) diff --git a/backend/test/unit/channel/domain/port/test_rate_limit_port.py b/backend/test/unit/channel/domain/port/test_rate_limit_port.py new file mode 100644 index 00000000..3c749136 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_rate_limit_port.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.rate_limit_port import RateLimitPort + + +def test_rate_limit_port_is_protocol() -> None: + assert hasattr(RateLimitPort, "check_and_incr") + assert hasattr(RateLimitPort, "is_locked") + assert hasattr(RateLimitPort, "reset") + + +class _FakeRateLimit: + async def check_and_incr( + self, key: str, *, max_attempts: int, window_seconds: int, lockout_seconds: int = 0 + ) -> bool: + return True + + async def is_locked(self, key: str) -> tuple[bool, int]: + return (False, 0) + + async def reset(self, key: str) -> None: + pass + + +def test_fake_rate_limit_is_instance() -> None: + obj = _FakeRateLimit() + assert isinstance(obj, RateLimitPort) diff --git a/backend/test/unit/channel/domain/port/test_signature_verify_port.py b/backend/test/unit/channel/domain/port/test_signature_verify_port.py new file mode 100644 index 00000000..e9e3a8b3 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_signature_verify_port.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort + + +def test_signature_verify_port_is_protocol() -> None: + assert hasattr(SignatureVerifyPort, "verify") + + +class _FakeSignatureVerify: + async def verify(self, payload: dict, signature: str, timestamp: str) -> bool: + return True + + +def test_fake_signature_verify_is_instance() -> None: + obj = _FakeSignatureVerify() + assert isinstance(obj, SignatureVerifyPort) diff --git a/backend/test/unit/channel/domain/port/test_sse_push_port.py b/backend/test/unit/channel/domain/port/test_sse_push_port.py new file mode 100644 index 00000000..65c3d0e7 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_sse_push_port.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from yuxi.channel.domain.port.sse_push_port import SsePushPort + + +def test_sse_push_port_is_protocol() -> None: + assert hasattr(SsePushPort, "push_event") + + +class _FakeSsePush: + async def push_event(self, session_id: str, data: dict) -> bool: + return True + + +def test_fake_sse_push_is_instance() -> None: + obj = _FakeSsePush() + assert isinstance(obj, SsePushPort) diff --git a/backend/test/unit/channel/domain/port/test_ws_connection_port.py b/backend/test/unit/channel/domain/port/test_ws_connection_port.py new file mode 100644 index 00000000..0aa4bc23 --- /dev/null +++ b/backend/test/unit/channel/domain/port/test_ws_connection_port.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable + +from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort + + +def test_ws_connection_port_is_protocol() -> None: + assert hasattr(WsConnectionPort, "channel_type") + assert hasattr(WsConnectionPort, "is_connected") + assert hasattr(WsConnectionPort, "supports_ack") + assert hasattr(WsConnectionPort, "start") + assert hasattr(WsConnectionPort, "stop") + + +class _FakeWsConnection: + @property + def channel_type(self) -> str: + return "web" + + @property + def is_connected(self) -> bool: + return True + + @property + def supports_ack(self) -> bool: + return False + + async def start( + self, + loop: asyncio.AbstractEventLoop, + on_message: Callable[[dict], Awaitable[None]], + ) -> None: + pass + + async def stop(self) -> None: + pass + + +def test_fake_ws_connection_is_instance() -> None: + obj = _FakeWsConnection() + assert isinstance(obj, WsConnectionPort) + + +def test_ws_connection_default_supports_ack() -> None: + class _MinimalWsConnection: + @property + def channel_type(self) -> str: + return "web" + + @property + def is_connected(self) -> bool: + return False + + async def start( + self, + loop: asyncio.AbstractEventLoop, + on_message: Callable[[dict], Awaitable[None]], + ) -> None: + pass + + async def stop(self) -> None: + pass + + obj = _MinimalWsConnection() + assert obj.supports_ack is False diff --git a/backend/test/unit/channel/domain/repository/__init__.py b/backend/test/unit/channel/domain/repository/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/repository/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..1ec7bb5c --- /dev/null +++ b/backend/test/unit/channel/domain/repository/test_binding_repository.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort +from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding + + +def test_binding_repository_port_is_protocol() -> None: + assert hasattr(BindingRepositoryPort, "find_binding") + assert hasattr(BindingRepositoryPort, "create_binding") + assert hasattr(BindingRepositoryPort, "update_binding") + 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: + return None + + async def create_binding( + self, + *, + channel_type: str, + account_id: str, + group_id: str, + agent_config_id: int, + created_by: str | None = None, + ) -> ChannelBinding: + return ChannelBinding( + id=1, + channel_type=channel_type, + account_id=account_id, + group_id=group_id, + agent_config_id=agent_config_id, + ) + + async def update_binding( + self, + binding_id: int, + *, + agent_config_id: int | None = None, + is_enabled: bool | None = None, + updated_by: str | None = None, + ) -> ChannelBinding | None: + return None + + async def delete_binding(self, binding_id: int, *, updated_by: str | None = None) -> bool: + return True + + async def get_binding(self, binding_id: int) -> ChannelBinding | None: + return None + + async def list_bindings( + self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50 + ) -> 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() + assert isinstance(obj, BindingRepositoryPort) 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 new file mode 100644 index 00000000..bca01eef --- /dev/null +++ b/backend/test/unit/channel/domain/repository/test_message_log_repository.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from yuxi.channel.domain.repository.message_log_repository import ( + MessageLogRepositoryPort, + MessageLogData, + MessageLogQuery, +) + + +def test_message_log_data_creation() -> None: + log = MessageLogData( + id=1, + trace_id="trace-1", + message_id="msg-1", + channel_type="feishu", + conversation_id=1, + session_id="sess-1", + direction="inbound", + sender_id="user-1", + content_summary="hello", + agent_config_id=5, + status="success", + pipeline_result="ok", + abort_reason=None, + worker_result="ok", + error_message=None, + processing_time_ms=100, + ) + assert log.id == 1 + assert log.trace_id == "trace-1" + assert log.direction == "inbound" + + +def test_message_log_query_defaults() -> None: + query = MessageLogQuery() + assert query.channel_type is None + assert query.conversation_id is None + assert query.status is None + assert query.direction is None + assert query.pipeline_result is None + assert query.worker_result is None + assert query.limit == 50 + assert query.offset == 0 + + +def test_message_log_query_with_values() -> None: + query = MessageLogQuery( + channel_type="web", + status="success", + limit=10, + offset=5, + ) + assert query.channel_type == "web" + assert query.status == "success" + assert query.limit == 10 + assert query.offset == 5 + + +def test_message_log_repository_port_is_protocol() -> None: + assert hasattr(MessageLogRepositoryPort, "create_log") + assert hasattr(MessageLogRepositoryPort, "update_pipeline_result") + assert hasattr(MessageLogRepositoryPort, "update_worker_result") + assert hasattr(MessageLogRepositoryPort, "get_by_trace_id") + assert hasattr(MessageLogRepositoryPort, "get_by_message_id") + assert hasattr(MessageLogRepositoryPort, "query_logs") + + +class _FakeMessageLogRepo: + async def create_log( + self, + *, + trace_id: str, + message_id: str, + channel_type: str, + direction: str, + sender_id: str | None = None, + content_summary: str | None = None, + agent_config_id: int | None = None, + session_id: str | None = None, + conversation_id: int | None = None, + ) -> MessageLogData: + return MessageLogData( + id=1, + trace_id=trace_id, + message_id=message_id, + channel_type=channel_type, + conversation_id=conversation_id, + session_id=session_id, + direction=direction, + sender_id=sender_id, + content_summary=content_summary, + agent_config_id=agent_config_id, + status="pending", + pipeline_result=None, + abort_reason=None, + worker_result=None, + error_message=None, + processing_time_ms=None, + ) + + async def update_pipeline_result( + self, + *, + trace_id: str, + message_id: str, + pipeline_result: str, + abort_reason: str | None = None, + ) -> bool: + return True + + async def update_worker_result( + self, + *, + trace_id: str, + message_id: str, + worker_result: str, + status: str, + error_message: str | None = None, + processing_time_ms: int | None = None, + agent_config_id: int | None = None, + session_id: int | None = None, + conversation_id: int | None = None, + ) -> bool: + return True + + async def get_by_trace_id(self, trace_id: str) -> list[MessageLogData]: + return [] + + async def get_by_message_id(self, message_id: str) -> list[MessageLogData]: + return [] + + async def query_logs(self, query: MessageLogQuery) -> list[MessageLogData]: + return [] + + +def test_fake_message_log_repo_is_instance() -> None: + obj = _FakeMessageLogRepo() + assert isinstance(obj, MessageLogRepositoryPort) diff --git a/backend/test/unit/channel/domain/repository/test_message_repository.py b/backend/test/unit/channel/domain/repository/test_message_repository.py new file mode 100644 index 00000000..53c147e7 --- /dev/null +++ b/backend/test/unit/channel/domain/repository/test_message_repository.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from yuxi.channel.domain.repository.message_repository import MessageRepositoryPort, ChannelMessageData + + +def test_channel_message_data_creation() -> None: + msg = ChannelMessageData( + id=1, + thread_id="thread-1", + role="user", + content="hello", + message_id="msg-1", + extra_metadata={"key": "value"}, + ) + assert msg.id == 1 + assert msg.thread_id == "thread-1" + assert msg.role == "user" + assert msg.content == "hello" + assert msg.message_id == "msg-1" + assert msg.extra_metadata == {"key": "value"} + + +def test_message_repository_port_is_protocol() -> None: + assert hasattr(MessageRepositoryPort, "save_user_message") + assert hasattr(MessageRepositoryPort, "save_assistant_message") + + +class _FakeMessageRepo: + async def save_user_message( + self, + *, + thread_id: str, + content: str, + message_id: str | None = None, + extra_metadata: dict | None = None, + ) -> ChannelMessageData: + return ChannelMessageData( + id=1, + thread_id=thread_id, + role="user", + content=content, + message_id=message_id, + extra_metadata=extra_metadata, + ) + + async def save_assistant_message( + self, + *, + thread_id: str, + content: str, + extra_metadata: dict | None = None, + ) -> ChannelMessageData: + return ChannelMessageData( + id=2, + thread_id=thread_id, + role="assistant", + content=content, + message_id=None, + extra_metadata=extra_metadata, + ) + + +def test_fake_message_repo_is_instance() -> None: + obj = _FakeMessageRepo() + assert isinstance(obj, MessageRepositoryPort) diff --git a/backend/test/unit/channel/domain/repository/test_outbox_repository.py b/backend/test/unit/channel/domain/repository/test_outbox_repository.py new file mode 100644 index 00000000..cda37448 --- /dev/null +++ b/backend/test/unit/channel/domain/repository/test_outbox_repository.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort +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, "get_by_id") + assert hasattr(OutboxRepositoryPort, "mark_retrying") + assert hasattr(OutboxRepositoryPort, "mark_sent") + assert hasattr(OutboxRepositoryPort, "mark_dead") + assert hasattr(OutboxRepositoryPort, "mark_failed") + assert hasattr(OutboxRepositoryPort, "list_outbox") + + +class _FakeOutboxRepo: + async def enqueue( + self, + *, + message_id: str, + session_id: str, + channel_type: str, + content: str, + trace_id: str | None = None, + extra_metadata: dict | None = None, + ) -> OutboxEntry: + return OutboxEntry( + id=1, + message_id=message_id, + session_id=session_id, + channel_type=channel_type, + content=content, + status="pending", + retry_count=0, + max_retries=3, + next_retry_at=None, + last_error=None, + trace_id=trace_id, + extra_metadata=extra_metadata, + ) + + async def fetch_pending(self, *, limit: int = 20) -> list[OutboxEntry]: + return [] + + async def get_by_id(self, entry_id: int) -> OutboxEntry | None: + return None + + async def mark_retrying(self, entry_id: int, *, last_error: str | None = None) -> OutboxEntry | None: + return None + + async def mark_sent(self, entry_id: int) -> bool: + return True + + async def mark_dead(self, entry_id: int, *, last_error: str) -> bool: + return True + + 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[OutboxEntry]: + return [] + + +def test_fake_outbox_repo_is_instance() -> None: + obj = _FakeOutboxRepo() + assert isinstance(obj, OutboxRepositoryPort) diff --git a/backend/test/unit/channel/domain/repository/test_session_repository.py b/backend/test/unit/channel/domain/repository/test_session_repository.py new file mode 100644 index 00000000..6ed22503 --- /dev/null +++ b/backend/test/unit/channel/domain/repository/test_session_repository.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort +from yuxi.channel.domain.model.session.channel_session import ChannelSession + + +def test_session_repository_port_is_protocol() -> None: + assert hasattr(SessionRepositoryPort, "get_or_create") + assert hasattr(SessionRepositoryPort, "get_by_thread_id") + assert hasattr(SessionRepositoryPort, "count_active_sessions") + + +class _FakeSessionRepo: + async def get_or_create( + self, + *, + channel_type: str, + account_id: str, + agent_config_id: int, + ) -> ChannelSession: + return ChannelSession( + id=1, + thread_id="thread-1", + user_id="user-1", + agent_id="agent-1", + channel_type=channel_type, + channel_session_key="key-1", + status="active", + title=None, + ) + + async def get_by_thread_id(self, thread_id: str) -> ChannelSession | None: + return None + + async def count_active_sessions(self, channel_type: str) -> int: + return 0 + + +def test_fake_session_repo_is_instance() -> None: + obj = _FakeSessionRepo() + assert isinstance(obj, SessionRepositoryPort) diff --git a/backend/test/unit/channel/domain/service/__init__.py b/backend/test/unit/channel/domain/service/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/domain/service/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/domain/service/test_message_context.py b/backend/test/unit/channel/domain/service/test_message_context.py new file mode 100644 index 00000000..e20d0220 --- /dev/null +++ b/backend/test/unit/channel/domain/service/test_message_context.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from yuxi.channel.domain.service.message_context import MessageContext, Span +from yuxi.channel.domain.model.message.unified_message import UnifiedMessage +from yuxi.channel.domain.model.message.peer import Peer +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +def test_span_defaults() -> None: + span = Span(middleware_name="auth") + assert span.middleware_name == "auth" + assert span.started_at == 0.0 + assert span.finished_at == 0.0 + assert span.duration_ms == 0.0 + assert span.aborted is False + assert span.skipped is False + + +def test_message_context_defaults() -> None: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.FEISHU, + sender=Peer(id="user-1", name="Alice"), + content="hello", + ) + ctx = MessageContext(message=msg) + assert ctx.message == msg + assert ctx.channel_type == "" + assert ctx.trace_id != "" + assert ctx.spans == [] + assert ctx.is_aborted is False + assert ctx.is_skipped is False + + +def test_message_context_with_channel_type() -> None: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.FEISHU, + sender=Peer(id="user-1", name="Alice"), + content="hello", + ) + ctx = MessageContext(message=msg, channel_type="feishu") + assert ctx.channel_type == "feishu" + + +def test_message_context_abort() -> None: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.FEISHU, + sender=Peer(id="user-1", name="Alice"), + content="hello", + ) + ctx = MessageContext(message=msg) + ctx.abort("auth failed", "AUTH_FAILED") + assert ctx.is_aborted is True + assert ctx.abort_reason == "auth failed" + assert ctx.abort_code == "AUTH_FAILED" + + +def test_message_context_skip() -> None: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.FEISHU, + sender=Peer(id="user-1", name="Alice"), + content="hello", + ) + ctx = MessageContext(message=msg) + ctx.skip() + assert ctx.is_skipped is True + + +def test_message_context_trace_id_unique() -> None: + msg = UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.FEISHU, + sender=Peer(id="user-1", name="Alice"), + content="hello", + ) + ctx1 = MessageContext(message=msg) + ctx2 = MessageContext(message=msg) + assert ctx1.trace_id != ctx2.trace_id diff --git a/backend/test/unit/channel/domain/service/test_pipeline.py b/backend/test/unit/channel/domain/service/test_pipeline.py new file mode 100644 index 00000000..8d00689f --- /dev/null +++ b/backend/test/unit/channel/domain/service/test_pipeline.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.service.pipeline import Pipeline +from yuxi.channel.domain.service.message_context import MessageContext, Span +from yuxi.channel.domain.middleware.middleware import Middleware, CallNext +from yuxi.channel.domain.model.message.unified_message import UnifiedMessage +from yuxi.channel.domain.model.message.peer import Peer +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +class _FakeMiddleware: + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + async def process(self, ctx: MessageContext, call_next: CallNext) -> MessageContext: + return await call_next(ctx) + + +class _AbortMiddleware: + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + async def process(self, ctx: MessageContext, call_next: CallNext) -> MessageContext: + ctx.abort("aborted", "TEST") + return ctx + + +class _SkipMiddleware: + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + async def process(self, ctx: MessageContext, call_next: CallNext) -> MessageContext: + ctx.skip() + return ctx + + +class _ErrorMiddleware: + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + async def process(self, ctx: MessageContext, call_next: CallNext) -> MessageContext: + raise RuntimeError("boom") + + +class _FakeMetrics: + def __init__(self) -> None: + self.middleware_durations: list[tuple[str, str, float]] = [] + self.pipeline_duration: tuple[str, float] | None = None + self.pipeline_total: str | None = None + self.pipeline_aborted: tuple[str, str] | None = None + + async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None: + self.middleware_durations.append((middleware, channel_type, duration_s)) + + async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None: + self.pipeline_duration = (channel_type, duration_s) + + async def record_pipeline_total(self, channel_type: str) -> None: + self.pipeline_total = channel_type + + async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None: + self.pipeline_aborted = (channel_type, reason) + + +@pytest.fixture +def sample_message() -> UnifiedMessage: + return UnifiedMessage( + message_id="msg-1", + channel_type=ChannelType.FEISHU, + sender=Peer(id="user-1", name="Alice"), + content="hello", + ) + + +@pytest.mark.asyncio +async def test_pipeline_empty(sample_message: UnifiedMessage) -> None: + pipeline = Pipeline([]) + ctx = MessageContext(message=sample_message) + result = await pipeline.execute(ctx) + assert result.message == sample_message + assert result.spans == [] + + +@pytest.mark.asyncio +async def test_pipeline_single_middleware(sample_message: UnifiedMessage) -> None: + pipeline = Pipeline([_FakeMiddleware("test")]) + ctx = MessageContext(message=sample_message) + result = await pipeline.execute(ctx) + assert result.message == sample_message + assert len(result.spans) == 1 + assert result.spans[0].middleware_name == "test" + assert result.spans[0].aborted is False + assert result.spans[0].skipped is False + + +@pytest.mark.asyncio +async def test_pipeline_multiple_middlewares(sample_message: UnifiedMessage) -> None: + pipeline = Pipeline([_FakeMiddleware("m1"), _FakeMiddleware("m2")]) + ctx = MessageContext(message=sample_message) + result = await pipeline.execute(ctx) + assert len(result.spans) == 2 + assert result.spans[0].middleware_name == "m1" + assert result.spans[1].middleware_name == "m2" + + +@pytest.mark.asyncio +async def test_pipeline_abort_stops_chain(sample_message: UnifiedMessage) -> None: + pipeline = Pipeline([_AbortMiddleware("abort"), _FakeMiddleware("after")]) + ctx = MessageContext(message=sample_message) + result = await pipeline.execute(ctx) + assert result.is_aborted is True + assert len(result.spans) == 1 + assert result.spans[0].middleware_name == "abort" + assert result.spans[0].aborted is True + + +@pytest.mark.asyncio +async def test_pipeline_skip_stops_chain(sample_message: UnifiedMessage) -> None: + pipeline = Pipeline([_SkipMiddleware("skip"), _FakeMiddleware("after")]) + ctx = MessageContext(message=sample_message) + result = await pipeline.execute(ctx) + assert result.is_skipped is True + assert len(result.spans) == 1 + assert result.spans[0].middleware_name == "skip" + assert result.spans[0].skipped is True + + +@pytest.mark.asyncio +async def test_pipeline_exception_raises(sample_message: UnifiedMessage) -> None: + pipeline = Pipeline([_ErrorMiddleware("error")]) + ctx = MessageContext(message=sample_message) + with pytest.raises(RuntimeError, match="boom"): + await pipeline.execute(ctx) + + +@pytest.mark.asyncio +async def test_pipeline_with_metrics(sample_message: UnifiedMessage) -> None: + metrics = _FakeMetrics() + pipeline = Pipeline([_FakeMiddleware("m1")], metrics=metrics) + ctx = MessageContext(message=sample_message, channel_type="feishu") + await pipeline.execute(ctx) + assert len(metrics.middleware_durations) == 1 + assert metrics.middleware_durations[0][0] == "m1" + assert metrics.pipeline_total == "feishu" + assert metrics.pipeline_duration is not None + assert metrics.pipeline_duration[0] == "feishu" + + +@pytest.mark.asyncio +async def test_pipeline_aborted_metrics(sample_message: UnifiedMessage) -> None: + metrics = _FakeMetrics() + pipeline = Pipeline([_AbortMiddleware("abort")], metrics=metrics) + ctx = MessageContext(message=sample_message, channel_type="web") + await pipeline.execute(ctx) + assert metrics.pipeline_aborted is not None + assert metrics.pipeline_aborted[0] == "web" + assert metrics.pipeline_aborted[1] == "TEST" + + +def test_pipeline_middlewares_property() -> None: + m1 = _FakeMiddleware("m1") + pipeline = Pipeline([m1]) + assert pipeline.middlewares == [m1] diff --git a/backend/test/unit/channel/domain/service/test_pipeline_builder.py b/backend/test/unit/channel/domain/service/test_pipeline_builder.py new file mode 100644 index 00000000..1cfd9d7e --- /dev/null +++ b/backend/test/unit/channel/domain/service/test_pipeline_builder.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from yuxi.channel.domain.service.pipeline_builder import PipelineBuilder +from yuxi.channel.domain.service.pipeline import Pipeline +from yuxi.channel.domain.middleware.middleware import Middleware, CallNext +from yuxi.channel.domain.service.message_context import MessageContext + + +class _FakeMiddleware: + def __init__(self, name: str) -> None: + self._name = name + + @property + def name(self) -> str: + return self._name + + async def process(self, ctx: MessageContext, call_next: CallNext) -> MessageContext: + return await call_next(ctx) + + +class _FakeMetrics: + async def record_middleware_duration(self, middleware: str, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_duration(self, channel_type: str, duration_s: float) -> None: + pass + + async def record_pipeline_total(self, channel_type: str) -> None: + pass + + async def record_pipeline_aborted(self, channel_type: str, reason: str) -> None: + pass + + +def test_pipeline_builder_empty() -> None: + builder = PipelineBuilder() + pipeline = builder.build() + assert isinstance(pipeline, Pipeline) + assert pipeline.middlewares == [] + + +def test_pipeline_builder_add() -> None: + builder = PipelineBuilder() + m1 = _FakeMiddleware("m1") + m2 = _FakeMiddleware("m2") + pipeline = builder.add(m1).add(m2).build() + assert len(pipeline.middlewares) == 2 + assert pipeline.middlewares[0].name == "m1" + assert pipeline.middlewares[1].name == "m2" + + +def test_pipeline_builder_chaining() -> None: + builder = PipelineBuilder() + m1 = _FakeMiddleware("m1") + result = builder.add(m1) + assert result is builder + + +def test_pipeline_builder_with_metrics() -> None: + builder = PipelineBuilder() + metrics = _FakeMetrics() + pipeline = builder.metrics(metrics).build() + assert isinstance(pipeline, Pipeline) diff --git a/backend/test/unit/channel/infrastructure/__init__.py b/backend/test/unit/channel/infrastructure/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/agent/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..c9b1c6e7 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/agent/test_agent_adapter.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import yuxi.services.chat_service # noqa: F401 +from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest +from yuxi.channel.domain.model.shared.channel_type import ChannelType +from yuxi.channel.infrastructure.agent.agent_adapter import AgentAdapter + + +@pytest.fixture +def fake_session_factory(): + db = AsyncMock() + session_factory = MagicMock() + session_factory.return_value.__aenter__ = AsyncMock(return_value=db) + session_factory.return_value.__aexit__ = AsyncMock(return_value=False) + return session_factory, db + + +@pytest.fixture +def adapter(fake_session_factory) -> AgentAdapter: + session_factory, _ = fake_session_factory + return AgentAdapter(session_factory=session_factory) + + +@pytest.fixture +def stream_request() -> StreamChatRequest: + return StreamChatRequest( + message_id="msg-1", + session_id="sess-1", + agent_config_id=1, + content="hello", + channel_type=ChannelType.FEISHU, + ) + + +class TestAgentAdapter: + @pytest.mark.asyncio + async def test_get_service_user_found(self, adapter: AgentAdapter, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_user = MagicMock() + fake_user.role = "channel_service" + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_user + db.execute.return_value = result_mock + + user = await adapter._get_service_user(db) + assert user is fake_user + + @pytest.mark.asyncio + async def test_get_service_user_fallback_to_superadmin(self, adapter: AgentAdapter, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_superadmin = MagicMock() + fake_superadmin.role = "superadmin" + + def side_effect(*args, **kwargs): + mock = MagicMock() + if len(db.execute.call_args_list) == 1: + mock.scalar_one_or_none.return_value = None + else: + mock.scalar_one_or_none.return_value = fake_superadmin + return mock + + db.execute.side_effect = side_effect + + user = await adapter._get_service_user(db) + assert user is fake_superadmin + + @pytest.mark.asyncio + async def test_get_service_user_not_found(self, adapter: AgentAdapter, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + db.execute.return_value = result_mock + + user = await adapter._get_service_user(db) + assert user is None + + @pytest.mark.asyncio + 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 + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_user + db.execute.return_value = result_mock + + with patch("yuxi.services.chat_service.stream_agent_chat") as mock_stream: + mock_stream.return_value = async_iter([ + '{"response": "hello", "status": "success"}', + '{"response": " world", "status": "success"}', + ]) + 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: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + db.execute.return_value = result_mock + + result = await adapter.stream_chat(stream_request) + 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: + session_factory, db = fake_session_factory + fake_user = MagicMock() + fake_user.id = 1 + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_user + db.execute.return_value = result_mock + + with patch("yuxi.services.chat_service.stream_agent_chat") as mock_stream: + mock_stream.return_value = async_iter([ + '{"response": "hello", "status": "success"}', + '{"status": "error", "error_message": "something wrong"}', + ]) + 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: + session_factory, db = fake_session_factory + fake_user = MagicMock() + fake_user.id = 1 + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_user + db.execute.return_value = result_mock + + with patch("yuxi.services.chat_service.stream_agent_chat") as mock_stream: + mock_stream.return_value = async_iter([ + '{"status": "error", "error_message": "something wrong"}', + ]) + result = await adapter.stream_chat(stream_request) + assert "Error: something wrong" in result + + +async def async_iter(items: list[str]): + for item in items: + yield item diff --git a/backend/test/unit/channel/infrastructure/config/__init__.py b/backend/test/unit/channel/infrastructure/config/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/config/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/config/test_channel_config.py b/backend/test/unit/channel/infrastructure/config/test_channel_config.py new file mode 100644 index 00000000..ee4c0488 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/config/test_channel_config.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from yuxi.channel.infrastructure.config.channel_config import ChannelConfig + + +@pytest.fixture +def config_file() -> Path: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + f.write(""" +auth: + token: test-token + password: test-password + max_attempts: 3 + lockout_seconds: 600 +access_policies: + policy1: allow +allow_from: + ip: 127.0.0.1 +keyword_blocklist: + - bad + - worse +hooks: + mappings: + - path: /hook1 +feishu: + verification_token: feishu-token + mode: webhook +mention_gate: + enabled: true +web: + endpoint: /web +""") + return Path(f.name) + + +@pytest.fixture +def config(config_file: Path) -> ChannelConfig: + return ChannelConfig(yaml_path=str(config_file)) + + +class TestChannelConfig: + def test_auth_token(self, config: ChannelConfig) -> None: + assert config.auth_token == "test-token" + + def test_auth_password(self, config: ChannelConfig) -> None: + assert config.auth_password == "test-password" + + def test_access_policies(self, config: ChannelConfig) -> None: + assert config.access_policies == {"policy1": "allow"} + + def test_allow_from(self, config: ChannelConfig) -> None: + assert config.allow_from == {"ip": "127.0.0.1"} + + def test_keyword_blocklist(self, config: ChannelConfig) -> None: + assert config.keyword_blocklist == {"bad", "worse"} + + def test_hook_mappings(self, config: ChannelConfig) -> None: + assert config.hook_mappings == [{"path": "/hook1"}] + + def test_feishu_verification_token(self, config: ChannelConfig) -> None: + assert config.feishu_verification_token == "feishu-token" + + def test_max_auth_attempts(self, config: ChannelConfig) -> None: + assert config.max_auth_attempts == 3 + + def test_lockout_seconds(self, config: ChannelConfig) -> None: + assert config.lockout_seconds == 600 + + def test_mention_gate_config(self, config: ChannelConfig) -> None: + assert config.mention_gate_config == {"enabled": True} + + def test_get_channel_config(self, config: ChannelConfig) -> None: + assert config.get_channel_config("web") == {"endpoint": "/web"} + + def test_raw_data(self, config: ChannelConfig) -> None: + assert config.raw_data["auth"]["token"] == "test-token" + + def test_missing_file_uses_defaults(self) -> None: + config = ChannelConfig(yaml_path="/nonexistent/path.yaml") + assert config.auth_token is None + assert config.access_policies == {} + assert config.keyword_blocklist == set() + + @pytest.mark.asyncio + async def test_reload(self, config: ChannelConfig, config_file: Path) -> None: + config_file.write_text("auth:\n token: new-token\n") + await config.reload() + assert config.auth_token == "new-token" + + @pytest.mark.asyncio + async def test_on_config_updated(self, config: ChannelConfig) -> None: + updated = await config.on_config_updated({"auth": {"token": "updated-token"}}) + assert "auth_token" in updated + assert config.auth_token == "updated-token" + + def test_feishu_ws_config_none_when_webhook(self, config: ChannelConfig) -> None: + assert config.feishu_ws_config is None + + def test_dingtalk_ws_config_none_when_webhook(self, config: ChannelConfig) -> None: + assert config.dingtalk_ws_config is None + + def test_feishu_ws_config_when_websocket(self, config_file: Path) -> None: + config_file.write_text("feishu:\n mode: websocket\n app_id: app1\n app_secret: secret1\n") + config = ChannelConfig(yaml_path=str(config_file)) + ws_config = config.feishu_ws_config + assert ws_config is not None + assert ws_config["app_id"] == "app1" + assert ws_config["mode"] == "websocket" 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 new file mode 100644 index 00000000..fcb7a2d0 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/config/test_redis_config_reload.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.infrastructure.config.redis_config_reload import RedisConfigReload + + +@pytest.fixture +def fake_redis() -> MagicMock: + redis = MagicMock() + redis.get = AsyncMock(return_value=None) + return redis + + +@pytest.fixture +def reload(fake_redis: MagicMock) -> RedisConfigReload: + return RedisConfigReload(redis=fake_redis) + + +@pytest.mark.asyncio +async def test_reload_loads_config(fake_redis: MagicMock, reload: RedisConfigReload) -> None: + fake_redis.get.return_value = json.dumps({"token": "abc"}).encode() + result = await reload.reload() + assert result == {"token": "abc"} + + +@pytest.mark.asyncio +async def test_reload_handles_string(fake_redis: MagicMock, reload: RedisConfigReload) -> None: + fake_redis.get.return_value = json.dumps({"token": "abc"}) + result = await reload.reload() + assert result == {"token": "abc"} + + +@pytest.mark.asyncio +async def test_reload_returns_empty_when_none(fake_redis: MagicMock, reload: RedisConfigReload) -> None: + fake_redis.get.return_value = None + result = await reload.reload() + assert result == {} + + +@pytest.mark.asyncio +async def test_get_current_triggers_reload(fake_redis: MagicMock, reload: RedisConfigReload) -> None: + fake_redis.get.return_value = json.dumps({"key": "val"}).encode() + result = await reload.get_current() + assert result == {"key": "val"} + + +@pytest.mark.asyncio +async def test_get_current_uses_cache(fake_redis: MagicMock, reload: RedisConfigReload) -> None: + reload._current = {"cached": True} + result = await reload.get_current() + assert result == {"cached": True} + fake_redis.get.assert_not_called() + + +@pytest.mark.asyncio +async def test_watch_calls_callback_on_message(fake_redis: MagicMock, reload: RedisConfigReload) -> None: + 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"})}, + ])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + task = await _run_watch(reload, callback) + callback.assert_awaited_once_with({"new": "config"}) + assert reload._current == {"new": "config"} + + +@pytest.mark.asyncio +async def test_watch_ignores_non_message(fake_redis: MagicMock, reload: RedisConfigReload) -> None: + callback = AsyncMock() + pubsub = MagicMock() + pubsub.subscribe = AsyncMock() + pubsub.listen = MagicMock(return_value=async_iter([ + {"type": "subscribe", "channel": "test"}, + ])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + task = await _run_watch(reload, callback) + callback.assert_not_called() + + +@pytest.mark.asyncio +async def test_watch_handles_callback_error(fake_redis: MagicMock, reload: RedisConfigReload) -> None: + 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"})}, + ])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + task = await _run_watch(reload, callback) + callback.assert_awaited() + + +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() + try: + await task + except asyncio.CancelledError: + pass + + +async def async_iter(items: list[dict]): + for item in items: + yield item diff --git a/backend/test/unit/channel/infrastructure/content_filter/__init__.py b/backend/test/unit/channel/infrastructure/content_filter/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/content_filter/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/content_filter/test_composite_content_filter.py b/backend/test/unit/channel/infrastructure/content_filter/test_composite_content_filter.py new file mode 100644 index 00000000..c870cdf1 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/content_filter/test_composite_content_filter.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.port.content_filter_port import ContentFilterPort, FilterResult +from yuxi.channel.infrastructure.content_filter.composite_content_filter import CompositeContentFilter + + +class _PassingFilter: + async def check(self, content: str, *, channel_type: str = "") -> FilterResult: + return FilterResult(passed=True) + + +class _BlockingFilter: + async def check(self, content: str, *, channel_type: str = "") -> FilterResult: + return FilterResult(passed=False, violations=["blocked"]) + + +class _SecondBlockingFilter: + async def check(self, content: str, *, channel_type: str = "") -> FilterResult: + return FilterResult(passed=False, violations=["also_blocked"]) + + +@pytest.mark.asyncio +async def test_all_pass() -> None: + filter_ = CompositeContentFilter([_PassingFilter(), _PassingFilter()]) + result = await filter_.check("safe content", channel_type="test") + assert result.passed is True + + +@pytest.mark.asyncio +async def test_first_block() -> None: + filter_ = CompositeContentFilter([_BlockingFilter(), _PassingFilter()]) + result = await filter_.check("bad content", channel_type="test") + assert result.passed is False + assert result.violations == ["blocked"] + + +@pytest.mark.asyncio +async def test_second_block() -> None: + filter_ = CompositeContentFilter([_PassingFilter(), _BlockingFilter()]) + result = await filter_.check("bad content", channel_type="test") + assert result.passed is False + assert result.violations == ["blocked"] + + +@pytest.mark.asyncio +async def test_empty_filters() -> None: + filter_ = CompositeContentFilter([]) + result = await filter_.check("any content", channel_type="test") + assert result.passed is True + + +@pytest.mark.asyncio +async def test_stops_at_first_block() -> None: + filter_ = CompositeContentFilter([_BlockingFilter(), _SecondBlockingFilter()]) + result = await filter_.check("bad content", channel_type="test") + assert result.passed is False + assert result.violations == ["blocked"] diff --git a/backend/test/unit/channel/infrastructure/content_filter/test_llm_content_filter.py b/backend/test/unit/channel/infrastructure/content_filter/test_llm_content_filter.py new file mode 100644 index 00000000..2ff31999 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/content_filter/test_llm_content_filter.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.domain.port.content_filter_port import ContentFilterPort, FilterResult +from yuxi.channel.infrastructure.content_filter.llm_content_filter import LlmContentFilter + + +class _FakeFallback: + async def check(self, content: str, *, channel_type: str = "") -> FilterResult: + return FilterResult(passed=False, violations=["fallback_blocked"]) + + +@pytest.mark.asyncio +async def test_check_delegates_to_fallback() -> None: + fallback = _FakeFallback() + filter_ = LlmContentFilter(fallback=fallback) + result = await filter_.check("some content", channel_type="test") + assert result.passed is False + assert result.violations == ["fallback_blocked"] + + +@pytest.mark.asyncio +async def test_check_passes_when_no_fallback() -> None: + filter_ = LlmContentFilter() + result = await filter_.check("some content", channel_type="test") + assert result.passed is True + + +@pytest.mark.asyncio +async def test_check_uses_timeout_default() -> None: + filter_ = LlmContentFilter() + assert filter_._timeout == 5.0 + + +@pytest.mark.asyncio +async def test_check_custom_timeout() -> None: + filter_ = LlmContentFilter(timeout=10.0) + assert filter_._timeout == 10.0 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 new file mode 100644 index 00000000..11f1e9ff --- /dev/null +++ b/backend/test/unit/channel/infrastructure/content_filter/test_redis_content_filter.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.infrastructure.content_filter.redis_content_filter import RedisContentFilter, BLOCKLIST_KEY + + +@pytest.fixture +def fake_redis() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def filter_(fake_redis: AsyncMock) -> RedisContentFilter: + return RedisContentFilter(redis=fake_redis) + + +@pytest.mark.asyncio +async def test_check_passes_when_blocklist_empty(filter_: RedisContentFilter, fake_redis: AsyncMock) -> None: + fake_redis.smembers.return_value = set() + result = await filter_.check("safe content", channel_type="test") + assert result.passed is True + assert result.violations == [] + + +@pytest.mark.asyncio +async def test_check_blocks_when_keyword_found(filter_: RedisContentFilter, fake_redis: AsyncMock) -> None: + fake_redis.smembers.return_value = {b"bad", b"worse"} + result = await filter_.check("this is bad content", channel_type="test") + assert result.passed is False + assert "bad" in result.violations + + +@pytest.mark.asyncio +async def test_check_passes_when_no_keyword_match(filter_: RedisContentFilter, fake_redis: AsyncMock) -> None: + fake_redis.smembers.return_value = {b"bad", b"worse"} + result = await filter_.check("this is clean content", channel_type="test") + assert result.passed is True + assert result.violations == [] + + +@pytest.mark.asyncio +async def test_check_uses_cache(filter_: RedisContentFilter, fake_redis: AsyncMock) -> None: + fake_redis.smembers.return_value = {b"bad"} + await filter_.check("content", channel_type="test") + await filter_.check("more content", channel_type="test") + assert fake_redis.smembers.call_count == 1 + + +@pytest.mark.asyncio +async def test_check_refreshes_cache_after_ttl(filter_: RedisContentFilter, fake_redis: AsyncMock) -> None: + 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 + + +@pytest.mark.asyncio +async def test_check_handles_string_items(filter_: RedisContentFilter, fake_redis: AsyncMock) -> None: + fake_redis.smembers.return_value = {"bad", "worse"} + result = await filter_.check("this is bad", channel_type="test") + assert result.passed is False + assert "bad" in result.violations + + +@pytest.mark.asyncio +async def test_check_case_insensitive(filter_: RedisContentFilter, fake_redis: AsyncMock) -> None: + fake_redis.smembers.return_value = {b"BAD"} + result = await filter_.check("this is bad content", channel_type="test") + assert result.passed is False + assert "bad" in result.violations diff --git a/backend/test/unit/channel/infrastructure/matching/test_aho_corasick_matcher.py b/backend/test/unit/channel/infrastructure/matching/test_aho_corasick_matcher.py new file mode 100644 index 00000000..ab376469 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/matching/test_aho_corasick_matcher.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.infrastructure.matching.aho_corasick_matcher import AhoCorasickMatcher + + +class TestAhoCorasickMatcher: + def test_match_found(self) -> None: + matcher = AhoCorasickMatcher({"bad", "worse", "terrible"}) + result = matcher.match("this is a bad message") + assert result == "bad" + + def test_match_not_found(self) -> None: + matcher = AhoCorasickMatcher({"bad", "worse"}) + result = matcher.match("this is a clean message") + assert result is None + + def test_match_case_insensitive(self) -> None: + matcher = AhoCorasickMatcher({"bad"}) + result = matcher.match("this is a BAD message") + assert result == "bad" + + def test_match_empty_keywords(self) -> None: + matcher = AhoCorasickMatcher(set()) + result = matcher.match("any content") + assert result is None + + def test_match_none_keywords(self) -> None: + matcher = AhoCorasickMatcher(None) + result = matcher.match("any content") + assert result is None + + def test_update_keywords(self) -> None: + matcher = AhoCorasickMatcher({"old"}) + matcher.update_keywords({"new", "fresh"}) + result = matcher.match("this is fresh") + assert result == "fresh" + + def test_update_keywords_removes_old(self) -> None: + matcher = AhoCorasickMatcher({"old"}) + matcher.update_keywords({"new"}) + result = matcher.match("this is old") + assert result is None + + def test_match_returns_first_match(self) -> None: + matcher = AhoCorasickMatcher({"bad", "worse"}) + result = matcher.match("bad and worse") + assert result in ("bad", "worse") + + def test_match_empty_content(self) -> None: + matcher = AhoCorasickMatcher({"bad"}) + result = matcher.match("") + assert result is None diff --git a/backend/test/unit/channel/infrastructure/messaging/__init__.py b/backend/test/unit/channel/infrastructure/messaging/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/messaging/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_publisher.py b/backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_publisher.py new file mode 100644 index 00000000..c6f0ae7f --- /dev/null +++ b/backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_publisher.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +import json +from dataclasses import asdict +from unittest.mock import AsyncMock + +import pytest + +from yuxi.channel.domain.event.agent_error import AgentError +from yuxi.channel.domain.event.message_blocked import MessageBlocked +from yuxi.channel.domain.event.message_received import MessageReceived +from yuxi.channel.domain.event.message_replied import MessageReplied +from yuxi.channel.infrastructure.messaging.redis_pubsub_publisher import RedisPubSubPublisher, PUBSUB_PREFIX + + +@pytest.fixture +def fake_redis() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def publisher(fake_redis: AsyncMock) -> RedisPubSubPublisher: + return RedisPubSubPublisher(redis=fake_redis) + + +@pytest.mark.asyncio +async def test_publish_message_received(publisher: RedisPubSubPublisher, fake_redis: AsyncMock) -> None: + event = MessageReceived( + message_id="msg-1", + channel_type="feishu", + session_id="sess-1", + sender_id="user-1", + content_summary="hello", + trace_id="trace-1", + ) + await publisher.publish(event) + + fake_redis.publish.assert_awaited_once() + channel, payload = fake_redis.publish.call_args[0] + assert channel == f"{PUBSUB_PREFIX}feishu" + data = json.loads(payload) + assert data["event_type"] == "message_received" + assert data["channel_type"] == "feishu" + assert data["session_id"] == "sess-1" + assert data["trace_id"] == "trace-1" + + +@pytest.mark.asyncio +async def test_publish_message_replied(publisher: RedisPubSubPublisher, fake_redis: AsyncMock) -> None: + event = MessageReplied( + message_id="msg-2", + channel_type="dingtalk", + session_id="sess-2", + reply_content_summary="reply", + trace_id="trace-2", + ) + await publisher.publish(event) + + fake_redis.publish.assert_awaited_once() + channel, _ = fake_redis.publish.call_args[0] + assert channel == f"{PUBSUB_PREFIX}dingtalk" + + +@pytest.mark.asyncio +async def test_publish_message_blocked(publisher: RedisPubSubPublisher, fake_redis: AsyncMock) -> None: + event = MessageBlocked( + message_id="msg-3", + channel_type="web", + session_id="sess-3", + reason="spam", + trace_id="trace-3", + ) + await publisher.publish(event) + + fake_redis.publish.assert_awaited_once() + _, payload = fake_redis.publish.call_args[0] + data = json.loads(payload) + assert data["event_type"] == "message_blocked" + + +@pytest.mark.asyncio +async def test_publish_agent_error(publisher: RedisPubSubPublisher, fake_redis: AsyncMock) -> None: + event = AgentError( + message_id="msg-4", + channel_type="feishu", + session_id="sess-4", + error_message="something went wrong", + trace_id="trace-4", + ) + await publisher.publish(event) + + fake_redis.publish.assert_awaited_once() + _, payload = fake_redis.publish.call_args[0] + data = json.loads(payload) + assert data["event_type"] == "agent_error" + + +@pytest.mark.asyncio +async def test_publish_includes_full_payload(publisher: RedisPubSubPublisher, fake_redis: AsyncMock) -> None: + event = MessageReceived( + message_id="msg-1", + channel_type="feishu", + session_id="sess-1", + sender_id="user-1", + content_summary="hello", + trace_id="trace-1", + ) + await publisher.publish(event) + + _, payload = fake_redis.publish.call_args[0] + data = json.loads(payload) + assert "payload" in data + assert data["payload"]["message_id"] == "msg-1" 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 new file mode 100644 index 00000000..465a73c0 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/messaging/test_redis_pubsub_subscriber.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.infrastructure.messaging.redis_pubsub_subscriber import RedisPubSubSubscriber + + +@pytest.fixture +def fake_redis() -> MagicMock: + redis = MagicMock() + redis.get = AsyncMock(return_value=None) + return redis + + +@pytest.fixture +def fake_sse() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def subscriber(fake_redis: MagicMock, fake_sse: AsyncMock) -> RedisPubSubSubscriber: + return RedisPubSubSubscriber(redis=fake_redis, sse_push=fake_sse) + + +async def _async_iter(items: list[dict]): + for item in items: + yield item + + +@pytest.mark.asyncio +async def test_start_creates_task(subscriber: RedisPubSubSubscriber, fake_redis: MagicMock) -> None: + pubsub = MagicMock() + pubsub.psubscribe = AsyncMock() + pubsub.unsubscribe = AsyncMock() + pubsub.aclose = AsyncMock() + pubsub.listen = MagicMock(return_value=_async_iter([])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + await subscriber.start() + assert subscriber._task is not None + subscriber._task.cancel() + try: + await subscriber._task + except asyncio.CancelledError: + pass + + +@pytest.mark.asyncio +async def test_stop_cancels_task(subscriber: RedisPubSubSubscriber, fake_redis: MagicMock) -> None: + pubsub = MagicMock() + pubsub.psubscribe = AsyncMock() + pubsub.unsubscribe = AsyncMock() + pubsub.aclose = AsyncMock() + pubsub.listen = MagicMock(return_value=_async_iter([])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + await subscriber.start() + await subscriber.stop() + assert subscriber._task is None + + +@pytest.mark.asyncio +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", + "payload": {"message_id": "msg-1"}, + } + pubsub = MagicMock() + pubsub.psubscribe = AsyncMock() + pubsub.unsubscribe = AsyncMock() + pubsub.aclose = AsyncMock() + pubsub.listen = MagicMock(return_value=_async_iter([ + {"type": "message", "data": json.dumps(event_data)}, + ])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + await subscriber.start() + await asyncio.sleep(0.05) + await subscriber.stop() + + fake_sse.push_event.assert_awaited() + args, _ = fake_sse.push_event.call_args + assert args[0] == "sess-1" + assert args[1]["event_type"] == "message_received" + + +@pytest.mark.asyncio +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"}, + ])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + await subscriber.start() + await asyncio.sleep(0.05) + await subscriber.stop() + + fake_sse.push_event.assert_not_called() + + +@pytest.mark.asyncio +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"}, + ])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + await subscriber.start() + await asyncio.sleep(0.05) + await subscriber.stop() + + fake_sse.push_event.assert_not_called() + + +@pytest.mark.asyncio +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"}, + } + pubsub = MagicMock() + pubsub.psubscribe = AsyncMock() + pubsub.unsubscribe = AsyncMock() + pubsub.aclose = AsyncMock() + pubsub.listen = MagicMock(return_value=_async_iter([ + {"type": "message", "data": json.dumps(event_data)}, + ])) + fake_redis.pubsub = MagicMock(return_value=pubsub) + + await subscriber.start() + await asyncio.sleep(0.05) + await subscriber.stop() + + fake_sse.push_event.assert_not_called() 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 new file mode 100644 index 00000000..0a2b5494 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/messaging/test_redis_stream_queue.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from yuxi.channel.infrastructure.messaging.redis_stream_queue import RedisStreamQueue, STREAM_KEY, CONSUMER_GROUP + + +@pytest.fixture +def fake_redis() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def queue(fake_redis: AsyncMock) -> RedisStreamQueue: + return RedisStreamQueue(redis=fake_redis, consumer_name="test-consumer") + + +@pytest.mark.asyncio +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 + ) + + +@pytest.mark.asyncio +async def test_ensure_group_ignores_busygroup(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + fake_redis.xgroup_create.side_effect = Exception("BUSYGROUP Consumer Group name already exists") + await queue.ensure_group() + fake_redis.xgroup_create.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_ensure_group_raises_on_other_errors(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + fake_redis.xgroup_create.side_effect = Exception("some other error") + with pytest.raises(Exception, match="some other error"): + await queue.ensure_group() + + +@pytest.mark.asyncio +async def test_enqueue_returns_message_id(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + fake_redis.xadd.return_value = b"123-0" + msg_id = await queue.enqueue({"key": "value"}) + assert msg_id == "123-0" + fake_redis.xadd.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_enqueue_handles_string_id(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + fake_redis.xadd.return_value = "456-0" + msg_id = await queue.enqueue({"key": "value"}) + assert msg_id == "456-0" + + +@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"}'}), + ]), + ] + messages = await queue.dequeue(count=1, block=1000) + assert len(messages) == 1 + assert messages[0]["key"] == "value" + assert messages[0]["_stream_id"] == "123-0" + + +@pytest.mark.asyncio +async def test_dequeue_returns_empty_when_no_messages(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + fake_redis.xreadgroup.return_value = [] + messages = await queue.dequeue() + assert messages == [] + + +@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"}'}), + ]), + ] + messages = await queue.dequeue() + assert len(messages) == 1 + assert messages[0]["key"] == "value" + + +@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"}), + ]), + ] + messages = await queue.dequeue() + assert len(messages) == 1 + assert messages[0] == {"_stream_id": "123-0"} + + +@pytest.mark.asyncio +async def test_ack_calls_xack(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + await queue.ack("123-0") + fake_redis.xack.assert_awaited_once_with(STREAM_KEY, CONSUMER_GROUP, "123-0") + + +@pytest.mark.asyncio +async def test_pending_count_returns_count(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + fake_redis.xpending.return_value = [5, b"min", b"max", []] + count = await queue.pending_count() + assert count == 5 + + +@pytest.mark.asyncio +async def test_pending_count_returns_zero_when_empty(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + fake_redis.xpending.return_value = [] + count = await queue.pending_count() + assert count == 0 + + +@pytest.mark.asyncio +async def test_pending_count_handles_tuple(queue: RedisStreamQueue, fake_redis: AsyncMock) -> None: + fake_redis.xpending.return_value = (3, b"min", b"max", []) + count = await queue.pending_count() + assert count == 3 diff --git a/backend/test/unit/channel/infrastructure/metrics/__init__.py b/backend/test/unit/channel/infrastructure/metrics/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/metrics/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/metrics/test_prometheus_metrics.py b/backend/test/unit/channel/infrastructure/metrics/test_prometheus_metrics.py new file mode 100644 index 00000000..11111070 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/metrics/test_prometheus_metrics.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import pytest + +from yuxi.channel.infrastructure.metrics.prometheus_metrics import PrometheusMetricsAdapter + + +@pytest.fixture +def metrics() -> PrometheusMetricsAdapter: + return PrometheusMetricsAdapter() + + +@pytest.mark.asyncio +async def test_record_middleware_duration(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_middleware_duration("auth", "feishu", 0.01) + + +@pytest.mark.asyncio +async def test_record_pipeline_duration(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_pipeline_duration("feishu", 0.1) + + +@pytest.mark.asyncio +async def test_record_pipeline_total(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_pipeline_total("feishu") + + +@pytest.mark.asyncio +async def test_record_pipeline_aborted(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_pipeline_aborted("feishu", "auth_failed") + + +@pytest.mark.asyncio +async def test_record_worker_dispatch_duration(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_worker_dispatch_duration("feishu", 5.0) + + +@pytest.mark.asyncio +async def test_record_worker_dispatch_total(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_worker_dispatch_total("feishu", "success") + + +@pytest.mark.asyncio +async def test_record_worker_step_duration(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_worker_step_duration("filter", "feishu", 0.05) + + +@pytest.mark.asyncio +async def test_set_outbox_pending(metrics: PrometheusMetricsAdapter) -> None: + await metrics.set_outbox_pending(10) + + +@pytest.mark.asyncio +async def test_record_outbox_enqueued(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_outbox_enqueued("feishu") + + +@pytest.mark.asyncio +async def test_record_outbox_retry_total(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_outbox_retry_total("feishu", "success") + + +@pytest.mark.asyncio +async def test_set_sse_connections(metrics: PrometheusMetricsAdapter) -> None: + await metrics.set_sse_connections(5) + + +@pytest.mark.asyncio +async def test_record_auth_attempts(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_auth_attempts("success") + + +@pytest.mark.asyncio +async def test_record_bot_loop_blocked(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_bot_loop_blocked("feishu", "group") + + +@pytest.mark.asyncio +async def test_record_access_policy_blocked(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_access_policy_blocked("feishu", "ip_block") + + +@pytest.mark.asyncio +async def test_record_mention_gate_skipped(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_mention_gate_skipped("feishu") + + +@pytest.mark.asyncio +async def test_record_hooks_received(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_hooks_received("/hook1") + + +@pytest.mark.asyncio +async def test_record_content_filter_blocked(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_content_filter_blocked("feishu") + + +@pytest.mark.asyncio +async def test_set_circuit_breaker_state(metrics: PrometheusMetricsAdapter) -> None: + await metrics.set_circuit_breaker_state(1, 2) + + +@pytest.mark.asyncio +async def test_record_circuit_breaker_rejected(metrics: PrometheusMetricsAdapter) -> None: + await metrics.record_circuit_breaker_rejected(1) diff --git a/backend/test/unit/channel/infrastructure/persistence/__init__.py b/backend/test/unit/channel/infrastructure/persistence/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/converter/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/persistence/converter/test_binding_converter.py b/backend/test/unit/channel/infrastructure/persistence/converter/test_binding_converter.py new file mode 100644 index 00000000..5154fbcc --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/converter/test_binding_converter.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import datetime +from unittest.mock import MagicMock + +import pytest + +from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding +from yuxi.channel.infrastructure.persistence.converter.binding_converter import to_domain + + +@pytest.fixture +def fake_row() -> MagicMock: + row = MagicMock() + row.id = 1 + row.channel_type = "feishu" + row.account_id = "acc-1" + row.group_id = "grp-1" + row.agent_config_id = 5 + row.session_key_strategy = "auto" + row.is_enabled = True + row.created_by = "user-1" + row.updated_by = "user-2" + row.created_at = datetime(2024, 1, 1, 12, 0, 0) + row.updated_at = datetime(2024, 1, 2, 12, 0, 0) + return row + + +class TestBindingConverter: + def test_to_domain(self, fake_row: MagicMock) -> None: + binding = to_domain(fake_row) + assert isinstance(binding, ChannelBinding) + assert binding.id == 1 + assert binding.channel_type == "feishu" + assert binding.account_id == "acc-1" + assert binding.group_id == "grp-1" + assert binding.agent_config_id == 5 + assert binding.session_key_strategy == "auto" + assert binding.is_enabled is True + assert binding.created_by == "user-1" + assert binding.updated_by == "user-2" + assert binding.created_at == "2024-01-01T12:00:00" + assert binding.updated_at == "2024-01-02T12:00:00" + + def test_to_domain_default_strategy(self, fake_row: MagicMock) -> None: + del fake_row.session_key_strategy + binding = to_domain(fake_row) + assert binding.session_key_strategy == "auto" + + def test_to_domain_none_datetimes(self, fake_row: MagicMock) -> None: + fake_row.created_at = None + fake_row.updated_at = None + binding = to_domain(fake_row) + assert binding.created_at == "" + assert binding.updated_at == "" diff --git a/backend/test/unit/channel/infrastructure/persistence/repository/__init__.py b/backend/test/unit/channel/infrastructure/persistence/repository/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/repository/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..97b2f200 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_binding_repository.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import json +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, +) + + +@pytest.fixture +def fake_session_factory(): + db = AsyncMock() + session_factory = MagicMock() + session_factory.return_value.__aenter__ = AsyncMock(return_value=db) + session_factory.return_value.__aexit__ = AsyncMock(return_value=False) + return session_factory, db + + +@pytest.fixture +def fake_cache() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def repo(fake_session_factory, fake_cache: AsyncMock) -> PgBindingRepository: + session_factory, _ = fake_session_factory + return PgBindingRepository(session_factory=session_factory, cache_port=fake_cache) + + +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, + }) + 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 + assert result.id == 1 + + @pytest.mark.asyncio + async def test_find_binding_cache_null_marker(self, repo: PgBindingRepository, fake_cache: AsyncMock) -> None: + fake_cache.get.return_value = _BINDING_NULL_MARKER + result = await repo.find_binding(channel_type="feishu", account_id="acc-1", group_id="grp-1") + assert result is None + + @pytest.mark.asyncio + 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() + fake_row.id = 1 + fake_row.channel_type = "feishu" + fake_row.account_id = "acc-1" + fake_row.group_id = "grp-1" + fake_row.agent_config_id = 5 + fake_row.session_key_strategy = "auto" + fake_row.is_enabled = True + fake_row.created_by = None + fake_row.updated_by = None + fake_row.created_at = None + fake_row.updated_at = None + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + + result = await repo.find_binding(channel_type="feishu", account_id="acc-1", group_id="grp-1") + assert result is not None + assert result.id == 1 + 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: + session_factory, db = fake_session_factory + fake_cache.get.return_value = None + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + db.execute.return_value = result_mock + + result = await repo.find_binding(channel_type="feishu", account_id="acc-1", group_id="grp-1") + assert result is None + fake_cache.set.assert_awaited() + + @pytest.mark.asyncio + async def test_create_binding(self, repo: PgBindingRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.channel_type = "feishu" + fake_row.account_id = "acc-1" + fake_row.group_id = "grp-1" + fake_row.agent_config_id = 5 + fake_row.session_key_strategy = "auto" + fake_row.is_enabled = True + fake_row.created_by = "user-1" + fake_row.updated_by = None + fake_row.created_at = None + fake_row.updated_at = None + db.flush = AsyncMock() + db.refresh = AsyncMock() + + result = await repo.create_binding( + channel_type="feishu", + account_id="acc-1", + group_id="grp-1", + agent_config_id=5, + created_by="user-1", + ) + assert result is not None + assert result.channel_type == "feishu" + + @pytest.mark.asyncio + async def test_update_binding(self, repo: PgBindingRepository, fake_session_factory, fake_cache: AsyncMock) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.channel_type = "feishu" + fake_row.account_id = "acc-1" + fake_row.group_id = "grp-1" + fake_row.agent_config_id = 5 + fake_row.session_key_strategy = "auto" + fake_row.is_enabled = True + fake_row.created_by = None + fake_row.updated_by = None + fake_row.created_at = None + fake_row.updated_at = None + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + + result = await repo.update_binding(1, agent_config_id=10) + assert result is not None + assert fake_row.agent_config_id == 10 + + @pytest.mark.asyncio + async def test_update_binding_not_found(self, repo: PgBindingRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + db.execute.return_value = result_mock + + result = await repo.update_binding(999, agent_config_id=10) + assert result is None + + @pytest.mark.asyncio + async def test_delete_binding(self, repo: PgBindingRepository, fake_session_factory, fake_cache: AsyncMock) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.channel_type = "feishu" + fake_row.account_id = "acc-1" + fake_row.group_id = "grp-1" + fake_row.is_deleted = 0 + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + + result = await repo.delete_binding(1) + assert result is True + assert fake_row.is_deleted == 1 + + @pytest.mark.asyncio + async def test_delete_binding_not_found(self, repo: PgBindingRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + db.execute.return_value = result_mock + + result = await repo.delete_binding(999) + assert result is False + + @pytest.mark.asyncio + async def test_get_binding(self, repo: PgBindingRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.channel_type = "feishu" + fake_row.account_id = "acc-1" + fake_row.group_id = "grp-1" + fake_row.agent_config_id = 5 + fake_row.session_key_strategy = "auto" + fake_row.is_enabled = True + fake_row.created_by = None + fake_row.updated_by = None + fake_row.created_at = None + fake_row.updated_at = None + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + + result = await repo.get_binding(1) + assert result is not None + assert result.id == 1 + + @pytest.mark.asyncio + async def test_list_bindings(self, repo: PgBindingRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.channel_type = "feishu" + fake_row.account_id = "acc-1" + fake_row.group_id = "grp-1" + fake_row.agent_config_id = 5 + fake_row.session_key_strategy = "auto" + fake_row.is_enabled = True + fake_row.created_by = None + fake_row.updated_by = None + fake_row.created_at = None + fake_row.updated_at = None + + result_mock = MagicMock() + result_mock.scalars.return_value.all.return_value = [fake_row] + db.execute.return_value = result_mock + + count_mock = MagicMock() + count_mock.scalar.return_value = 1 + db.execute.side_effect = [count_mock, result_mock] + + 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 new file mode 100644 index 00000000..f8f9ad37 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_log_repository.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +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, +) + + +@pytest.fixture +def fake_session_factory(): + db = AsyncMock() + session_factory = MagicMock() + session_factory.return_value.__aenter__ = AsyncMock(return_value=db) + session_factory.return_value.__aexit__ = AsyncMock(return_value=False) + return session_factory, db + + +@pytest.fixture +def repo(fake_session_factory) -> PgMessageLogRepository: + session_factory, _ = fake_session_factory + return PgMessageLogRepository(session_factory=session_factory) + + +class TestPgMessageLogRepository: + @pytest.mark.asyncio + async def test_create_log(self, repo: PgMessageLogRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.trace_id = "trace-1" + fake_row.message_id = "msg-1" + fake_row.channel_type = "feishu" + fake_row.direction = "inbound" + fake_row.sender_id = "user-1" + fake_row.content_summary = "hello" + fake_row.agent_config_id = 5 + fake_row.session_id = "sess-1" + fake_row.conversation_id = 10 + fake_row.status = "received" + fake_row.pipeline_result = None + fake_row.abort_reason = None + fake_row.worker_result = None + fake_row.error_message = None + fake_row.processing_time_ms = None + + db.flush = AsyncMock() + db.refresh = AsyncMock() + + result = await repo.create_log( + trace_id="trace-1", + message_id="msg-1", + channel_type="feishu", + direction="inbound", + sender_id="user-1", + content_summary="hello", + agent_config_id=5, + session_id="sess-1", + conversation_id=10, + ) + assert result.trace_id == "trace-1" + assert result.message_id == "msg-1" + assert result.status == "received" + + @pytest.mark.asyncio + async def test_create_log_truncates_long_summary(self, repo: PgMessageLogRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.trace_id = "trace-1" + fake_row.message_id = "msg-1" + fake_row.channel_type = "feishu" + fake_row.direction = "inbound" + fake_row.sender_id = None + fake_row.content_summary = "x" * 600 + fake_row.agent_config_id = None + fake_row.session_id = None + fake_row.conversation_id = None + fake_row.status = "received" + fake_row.pipeline_result = None + fake_row.abort_reason = None + fake_row.worker_result = None + fake_row.error_message = None + fake_row.processing_time_ms = None + + db.flush = AsyncMock() + db.refresh = AsyncMock() + + result = await repo.create_log( + trace_id="trace-1", + message_id="msg-1", + channel_type="feishu", + direction="inbound", + content_summary="x" * 600, + ) + assert result is not None + + @pytest.mark.asyncio + async def test_update_pipeline_result(self, repo: PgMessageLogRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.rowcount = 1 + db.execute.return_value = result_mock + + result = await repo.update_pipeline_result( + trace_id="trace-1", + message_id="msg-1", + pipeline_result="accepted", + ) + assert result is True + + @pytest.mark.asyncio + async def test_update_worker_result(self, repo: PgMessageLogRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.rowcount = 1 + db.execute.return_value = result_mock + + result = await repo.update_worker_result( + trace_id="trace-1", + message_id="msg-1", + worker_result="success", + status="completed", + error_message=None, + processing_time_ms=100, + ) + assert result is True + + @pytest.mark.asyncio + async def test_get_by_trace_id(self, repo: PgMessageLogRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.trace_id = "trace-1" + fake_row.message_id = "msg-1" + fake_row.channel_type = "feishu" + fake_row.direction = "inbound" + fake_row.sender_id = None + fake_row.content_summary = None + fake_row.agent_config_id = None + fake_row.session_id = None + fake_row.conversation_id = None + fake_row.status = "received" + fake_row.pipeline_result = None + fake_row.abort_reason = None + fake_row.worker_result = None + 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 + + result = await repo.get_by_trace_id("trace-1") + assert len(result) == 1 + assert result[0].trace_id == "trace-1" + + @pytest.mark.asyncio + async def test_get_by_message_id(self, repo: PgMessageLogRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.trace_id = "trace-1" + fake_row.message_id = "msg-1" + fake_row.channel_type = "feishu" + fake_row.direction = "inbound" + fake_row.sender_id = None + fake_row.content_summary = None + fake_row.agent_config_id = None + fake_row.session_id = None + fake_row.conversation_id = None + fake_row.status = "received" + fake_row.pipeline_result = None + fake_row.abort_reason = None + fake_row.worker_result = None + 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 + + result = await repo.get_by_message_id("msg-1") + assert len(result) == 1 + assert result[0].message_id == "msg-1" + + @pytest.mark.asyncio + async def test_query_logs(self, repo: PgMessageLogRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.trace_id = "trace-1" + fake_row.message_id = "msg-1" + fake_row.channel_type = "feishu" + fake_row.direction = "inbound" + fake_row.sender_id = None + fake_row.content_summary = None + fake_row.agent_config_id = None + fake_row.session_id = None + fake_row.conversation_id = None + fake_row.status = "received" + fake_row.pipeline_result = None + fake_row.abort_reason = None + fake_row.worker_result = None + 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 + + query = MessageLogQuery(channel_type="feishu", status="received") + result = await repo.query_logs(query) + assert len(result) == 1 + assert result[0].channel_type == "feishu" 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 new file mode 100644 index 00000000..58002e47 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_message_repository.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yuxi.channel.infrastructure.persistence.repository.pg_message_repository import PgMessageRepository + + +@pytest.fixture +def fake_session_factory(): + db = AsyncMock() + session_factory = MagicMock() + session_factory.return_value.__aenter__ = AsyncMock(return_value=db) + session_factory.return_value.__aexit__ = AsyncMock(return_value=False) + return session_factory, db + + +@pytest.fixture +def repo(fake_session_factory) -> PgMessageRepository: + session_factory, _ = fake_session_factory + return PgMessageRepository(session_factory=session_factory) + + +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_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 + + 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" + + @pytest.mark.asyncio + async def test_save_user_message_without_message_id(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 + + result = await repo.save_user_message( + thread_id="thread-1", + 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_msg = MagicMock() + fake_msg.id = 2 + + 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 + + result = await repo.save_assistant_message( + thread_id="thread-1", + 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 new file mode 100644 index 00000000..d72d24ab --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_outbox_repository.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.infrastructure.persistence.repository.pg_outbox_repository import PgOutboxRepository + + +@pytest.fixture +def fake_session_factory(): + db = AsyncMock() + session_factory = MagicMock() + session_factory.return_value.__aenter__ = AsyncMock(return_value=db) + session_factory.return_value.__aexit__ = AsyncMock(return_value=False) + return session_factory, db + + +@pytest.fixture +def fake_redis() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def repo(fake_session_factory, fake_redis: AsyncMock) -> PgOutboxRepository: + session_factory, _ = fake_session_factory + return PgOutboxRepository(session_factory=session_factory, redis=fake_redis) + + +class TestPgOutboxRepository: + @pytest.mark.asyncio + async def test_enqueue(self, repo: PgOutboxRepository, fake_session_factory, fake_redis: AsyncMock) -> None: + session_factory, db = fake_session_factory + + async def _fake_refresh(obj): + obj.id = 1 + + db.flush = AsyncMock() + db.refresh = AsyncMock(side_effect=_fake_refresh) + + result = await repo.enqueue( + message_id="msg-1", + session_id="sess-1", + channel_type="feishu", + content="hello", + trace_id="trace-1", + ) + assert result.id == 1 + assert result.status == "pending" + fake_redis.publish.assert_awaited_once() + + @pytest.mark.asyncio + async def test_enqueue_without_redis(self, fake_session_factory) -> None: + session_factory, db = fake_session_factory + repo = PgOutboxRepository(session_factory=session_factory, redis=None) + + async def _fake_refresh(obj): + obj.id = 1 + + db.flush = AsyncMock() + db.refresh = AsyncMock(side_effect=_fake_refresh) + + result = await repo.enqueue( + message_id="msg-1", + session_id="sess-1", + channel_type="feishu", + content="hello", + ) + assert result.id == 1 + + @pytest.mark.asyncio + async def test_fetch_pending(self, repo: PgOutboxRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.message_id = "msg-1" + fake_row.session_id = "sess-1" + fake_row.channel_type = "feishu" + fake_row.content = "hello" + fake_row.status = "pending" + fake_row.retry_count = 0 + fake_row.max_retries = 3 + fake_row.next_retry_at = None + fake_row.last_error = None + fake_row.trace_id = None + fake_row.extra_metadata = None + + result_mock = MagicMock() + result_mock.scalars.return_value.all.return_value = [fake_row] + db.execute.return_value = result_mock + + result = await repo.fetch_pending(limit=10) + assert len(result) == 1 + assert result[0].status == "pending" + + @pytest.mark.asyncio + async def test_get_by_id(self, repo: PgOutboxRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.message_id = "msg-1" + fake_row.session_id = "sess-1" + fake_row.channel_type = "feishu" + fake_row.content = "hello" + fake_row.status = "pending" + fake_row.retry_count = 0 + fake_row.max_retries = 3 + fake_row.next_retry_at = None + fake_row.last_error = None + fake_row.trace_id = None + fake_row.extra_metadata = None + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + + result = await repo.get_by_id(1) + assert result is not None + assert result.id == 1 + + @pytest.mark.asyncio + async def test_get_by_id_not_found(self, repo: PgOutboxRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + db.execute.return_value = result_mock + + result = await repo.get_by_id(999) + assert result is None + + @pytest.mark.asyncio + async def test_mark_retrying(self, repo: PgOutboxRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.retry_count = 0 + fake_row.status = "pending" + 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 == "retrying" + assert fake_row.retry_count == 1 + + @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 + 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_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 + 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 + 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: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.message_id = "msg-1" + fake_row.session_id = "sess-1" + fake_row.channel_type = "feishu" + fake_row.content = "hello" + fake_row.status = "pending" + fake_row.retry_count = 0 + fake_row.max_retries = 3 + fake_row.next_retry_at = None + fake_row.last_error = None + fake_row.trace_id = None + fake_row.extra_metadata = None + + result_mock = MagicMock() + 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") + 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 new file mode 100644 index 00000000..5ff879ca --- /dev/null +++ b/backend/test/unit/channel/infrastructure/persistence/repository/test_pg_session_repository.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from sqlalchemy.exc import IntegrityError + +from yuxi.channel.infrastructure.persistence.repository.pg_session_repository import PgSessionRepository + + +@pytest.fixture +def fake_session_factory(): + db = AsyncMock() + session_factory = MagicMock() + session_factory.return_value.__aenter__ = AsyncMock(return_value=db) + session_factory.return_value.__aexit__ = AsyncMock(return_value=False) + return session_factory, db + + +@pytest.fixture +def fake_cache() -> AsyncMock: + return AsyncMock() + + +@pytest.fixture +def repo(fake_session_factory, fake_cache: AsyncMock) -> PgSessionRepository: + session_factory, _ = fake_session_factory + return PgSessionRepository( + session_factory=session_factory, + cache_port=fake_cache, + agent_id_resolver=lambda x: AsyncMock(return_value="agent-1")(), + ) + + +class TestPgSessionRepository: + @pytest.mark.asyncio + async def test_get_or_create_returns_existing(self, repo: PgSessionRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.thread_id = "thread-1" + fake_row.user_id = "user-1" + fake_row.agent_id = "agent-1" + fake_row.channel_type = "feishu" + fake_row.channel_session_key = "user-1" + fake_row.status = "active" + fake_row.title = None + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + + result = await repo.get_or_create( + channel_type="feishu", + account_id="user-1", + agent_config_id=1, + ) + 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: + session_factory, db = fake_session_factory + fake_cache.set.return_value = True + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + db.execute.return_value = result_mock + db.flush = AsyncMock() + db.refresh = AsyncMock() + + result = await repo.get_or_create( + channel_type="feishu", + account_id="user-1", + agent_config_id=1, + ) + assert result is not None + 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: + session_factory, db = fake_session_factory + fake_cache.set.return_value = True + + fake_row = MagicMock() + fake_row.id = 1 + fake_row.thread_id = "thread-1" + fake_row.user_id = "user-1" + fake_row.agent_id = "agent-1" + fake_row.channel_type = "feishu" + fake_row.channel_session_key = "user-1" + fake_row.status = "active" + fake_row.title = None + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + db.flush.side_effect = [IntegrityError("", "", ""), None] + + result = await repo.get_or_create( + channel_type="feishu", + account_id="user-1", + agent_config_id=1, + ) + assert result.thread_id == "thread-1" + + @pytest.mark.asyncio + async def test_get_by_thread_id(self, repo: PgSessionRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + fake_row = MagicMock() + fake_row.id = 1 + fake_row.thread_id = "thread-1" + fake_row.user_id = "user-1" + fake_row.agent_id = "agent-1" + fake_row.channel_type = "feishu" + fake_row.channel_session_key = "user-1" + fake_row.status = "active" + fake_row.title = None + + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = fake_row + db.execute.return_value = result_mock + + result = await repo.get_by_thread_id("thread-1") + assert result is not None + assert result.thread_id == "thread-1" + + @pytest.mark.asyncio + async def test_get_by_thread_id_not_found(self, repo: PgSessionRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.scalar_one_or_none.return_value = None + db.execute.return_value = result_mock + + result = await repo.get_by_thread_id("not-found") + assert result is None + + @pytest.mark.asyncio + async def test_count_active_sessions(self, repo: PgSessionRepository, fake_session_factory) -> None: + session_factory, db = fake_session_factory + result_mock = MagicMock() + result_mock.scalar.return_value = 5 + db.execute.return_value = result_mock + + result = await repo.count_active_sessions("feishu") + assert result == 5 diff --git a/backend/test/unit/channel/infrastructure/security/__init__.py b/backend/test/unit/channel/infrastructure/security/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/security/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/backend/test/unit/channel/infrastructure/security/test_hmac_signature_verifier.py b/backend/test/unit/channel/infrastructure/security/test_hmac_signature_verifier.py new file mode 100644 index 00000000..460ba227 --- /dev/null +++ b/backend/test/unit/channel/infrastructure/security/test_hmac_signature_verifier.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from unittest.mock import patch + +import pytest + +from yuxi.channel.infrastructure.security.hmac_signature_verifier import HmacSignatureVerifier + + +@pytest.fixture +def verifier() -> HmacSignatureVerifier: + return HmacSignatureVerifier(secret="test-secret", timestamp_tolerance=300) + + +@pytest.mark.asyncio +async def test_verify_success(verifier: HmacSignatureVerifier) -> None: + payload = {"key": "value", "foo": "bar"} + timestamp = str(time.time()) + message = f"{timestamp}.{json.dumps(payload, sort_keys=True, ensure_ascii=False)}" + signature = hmac.new( + "test-secret".encode(), + message.encode(), + hashlib.sha256, + ).hexdigest() + + result = await verifier.verify(payload, signature, timestamp) + assert result is True + + +@pytest.mark.asyncio +async def test_verify_wrong_signature(verifier: HmacSignatureVerifier) -> None: + payload = {"key": "value"} + timestamp = str(time.time()) + + result = await verifier.verify(payload, "wrong-signature", timestamp) + assert result is False + + +@pytest.mark.asyncio +async def test_verify_expired_timestamp(verifier: HmacSignatureVerifier) -> None: + payload = {"key": "value"} + old_timestamp = str(time.time() - 400) + message = f"{old_timestamp}.{json.dumps(payload, sort_keys=True, ensure_ascii=False)}" + signature = hmac.new( + "test-secret".encode(), + message.encode(), + hashlib.sha256, + ).hexdigest() + + result = await verifier.verify(payload, signature, old_timestamp) + assert result is False + + +@pytest.mark.asyncio +async def test_verify_invalid_timestamp(verifier: HmacSignatureVerifier) -> None: + payload = {"key": "value"} + + result = await verifier.verify(payload, "some-sig", "not-a-number") + assert result is False + + +@pytest.mark.asyncio +async def test_verify_disabled_when_no_secret() -> None: + verifier = HmacSignatureVerifier(secret="") + result = await verifier.verify({"key": "value"}, "any-sig", "any-ts") + assert result is True + + +@pytest.mark.asyncio +async def test_verify_tolerance_custom() -> None: + verifier = HmacSignatureVerifier(secret="test-secret", timestamp_tolerance=10) + payload = {"key": "value"} + old_timestamp = str(time.time() - 20) + message = f"{old_timestamp}.{json.dumps(payload, sort_keys=True, ensure_ascii=False)}" + signature = hmac.new( + "test-secret".encode(), + message.encode(), + hashlib.sha256, + ).hexdigest() + + result = await verifier.verify(payload, signature, old_timestamp) + assert result is False diff --git a/backend/test/unit/channel/interfaces/__init__.py b/backend/test/unit/channel/interfaces/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/interfaces/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/auth/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..ae3cd0e8 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/auth/test_depends.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException + +from yuxi.channel.interfaces.rest.auth.depends import channel_auth_depends + + +class _FakeRateLimiter: + def __init__(self): + self.locked = False + self.remaining = 0 + self.attempts_allowed = True + + 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: + return self.attempts_allowed + + async def reset(self, key: str) -> None: + pass + + +class _FakeAuthService: + def __init__(self, token: str | None = "test-token", password: str | None = None): + self._token = token + self._password = password + self._rate_limiter = _FakeRateLimiter() + + async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]: + if not self._token and not self._password: + return True, "" + + locked, remaining = await self._rate_limiter.is_locked(f"channel:auth:lockout:{client_id}") + if locked: + return False, f"auth rate limited, retry after {remaining}s" + + if self._check_credential(auth_header): + await self._rate_limiter.reset(f"channel:auth:attempts:{client_id}") + return True, "" + + allowed = await self._rate_limiter.check_and_incr( + f"channel:auth:attempts:{client_id}", + max_attempts=5, + window_seconds=300, + lockout_seconds=300, + ) + if not allowed: + return False, "auth rate limited" + + return False, "auth failed" + + def _check_credential(self, auth_header: str) -> bool: + if auth_header.startswith("Bearer "): + if self._token: + return auth_header[7:] == self._token + 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) + return pwd == self._password + except Exception: + pass + return False + + +class _FakeChannelContainer: + def __init__(self, auth_service=None): + self.auth_service = auth_service or _FakeAuthService() + + +class _FakeRequest: + def __init__(self): + self.app = type("App", (), {"state": type("State", (), {})()})() + + +@pytest.mark.asyncio +async def test_channel_auth_depends_with_valid_bearer_token(): + container = _FakeChannelContainer(auth_service=_FakeAuthService(token="secret-token")) + result = await channel_auth_depends( + authorization="Bearer secret-token", + token_query=None, + channel=container, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_channel_auth_depends_with_valid_query_token(): + container = _FakeChannelContainer(auth_service=_FakeAuthService(token="secret-token")) + result = await channel_auth_depends( + authorization=None, + token_query="secret-token", + channel=container, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_channel_auth_depends_with_invalid_token_raises_401(): + container = _FakeChannelContainer(auth_service=_FakeAuthService(token="secret-token")) + with pytest.raises(HTTPException) as exc_info: + await channel_auth_depends( + authorization="Bearer wrong-token", + token_query=None, + channel=container, + ) + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_channel_auth_depends_with_no_credentials_raises_401(): + container = _FakeChannelContainer(auth_service=_FakeAuthService(token="secret-token")) + with pytest.raises(HTTPException) as exc_info: + await channel_auth_depends( + authorization=None, + token_query=None, + channel=container, + ) + assert exc_info.value.status_code == 401 + + +@pytest.mark.asyncio +async def test_channel_auth_depends_with_rate_limited_raises_429(): + auth_service = _FakeAuthService(token="secret-token") + auth_service._rate_limiter.locked = True + auth_service._rate_limiter.remaining = 60 + container = _FakeChannelContainer(auth_service=auth_service) + with pytest.raises(HTTPException) as exc_info: + await channel_auth_depends( + authorization="Bearer wrong-token", + token_query=None, + channel=container, + ) + assert exc_info.value.status_code == 429 + assert "rate limited" in exc_info.value.detail + + +@pytest.mark.asyncio +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}", + token_query=None, + channel=container, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_channel_auth_depends_no_auth_configured(): + container = _FakeChannelContainer(auth_service=_FakeAuthService(token=None, password=None)) + result = await channel_auth_depends( + authorization=None, + token_query=None, + channel=container, + ) + assert result is True diff --git a/backend/test/unit/channel/interfaces/rest/middleware/__init__.py b/backend/test/unit/channel/interfaces/rest/middleware/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/middleware/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..fc7f791b --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/middleware/test_exception_handler.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import pytest +from fastapi import HTTPException, Request +from fastapi.responses import JSONResponse + +from yuxi.channel.interfaces.rest.middleware.exception_handler import channel_exception_handler, ErrorResponse + + +class _FakeRequest: + def __init__(self, trace_id: str | None = None): + self.state = type("State", (), {"trace_id": trace_id})() + self.headers = {} + + +@pytest.mark.asyncio +async def test_channel_exception_handler_with_http_exception(): + request = _FakeRequest(trace_id="test-trace-id") + exc = HTTPException(status_code=404, detail="not found") + response = await channel_exception_handler(request, exc) + + assert isinstance(response, JSONResponse) + assert response.status_code == 404 + body = response.body.decode() + assert "not found" in body + assert "test-trace-id" in body + + +@pytest.mark.asyncio +async def test_channel_exception_handler_with_generic_exception(): + request = _FakeRequest(trace_id="test-trace-id") + exc = ValueError("something went wrong") + response = await channel_exception_handler(request, exc) + + assert isinstance(response, JSONResponse) + assert response.status_code == 500 + body = response.body.decode() + assert "internal server error" in body + assert "test-trace-id" in body + + +@pytest.mark.asyncio +async def test_channel_exception_handler_generates_trace_id_when_missing(): + request = _FakeRequest(trace_id=None) + exc = HTTPException(status_code=400, detail="bad request") + response = await channel_exception_handler(request, exc) + + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + body = response.body.decode() + 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 + + +def test_error_response_model(): + response = ErrorResponse(error="test error", code="TEST_CODE", detail={"key": "value"}, trace_id="trace-123") + assert response.error == "test error" + assert response.code == "TEST_CODE" + assert response.detail == {"key": "value"} + assert response.trace_id == "trace-123" 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 new file mode 100644 index 00000000..051e565e --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/middleware/test_trace_id.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import pytest +from starlette.requests import Request +from starlette.responses import Response + +from yuxi.channel.interfaces.rest.middleware.trace_id import TraceIdMiddleware + + +class _FakeApp: + pass + + +@pytest.mark.asyncio +async def test_trace_id_middleware_extracts_from_header(): + middleware = TraceIdMiddleware(app=_FakeApp()) + + async def call_next(request: Request): + response = Response(content="ok") + return response + + 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" + assert request.state.trace_id == "existing-trace-id" + + +@pytest.mark.asyncio +async def test_trace_id_middleware_extracts_from_request_id_header(): + middleware = TraceIdMiddleware(app=_FakeApp()) + + async def call_next(request: Request): + response = Response(content="ok") + return response + + 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" + assert request.state.trace_id == "request-id-123" + + +@pytest.mark.asyncio +async def test_trace_id_middleware_generates_uuid_when_no_header(): + middleware = TraceIdMiddleware(app=_FakeApp()) + + async def call_next(request: Request): + response = Response(content="ok") + return response + + request = Request(scope={ + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + }) + + response = await middleware.dispatch(request, call_next) + assert "X-Trace-Id" in response.headers + assert request.state.trace_id is not None + assert len(request.state.trace_id) > 0 diff --git a/backend/test/unit/channel/interfaces/rest/router/__init__.py b/backend/test/unit/channel/interfaces/rest/router/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..ea1b4f46 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/test_bindings.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +from yuxi.channel.interfaces.rest.router.bindings import router +from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding + + +class _FakeBindingService: + def __init__(self): + 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: + binding = ChannelBinding( + id=self._next_id, + channel_type=channel_type, + account_id=account_id, + group_id=group_id, + agent_config_id=agent_config_id, + created_at="2024-01-01T00:00:00Z", + ) + self._bindings[self._next_id] = binding + 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]: + 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) + + async def delete(self, binding_id: int) -> bool: + if binding_id in self._bindings: + del self._bindings[binding_id] + return True + return False + + +class _FakeAuthService: + async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]: + return True, "" + + +class _FakeChannelContainer: + def __init__(self): + self.binding_service = _FakeBindingService() + self.auth_service = _FakeAuthService() + + +def _build_app() -> FastAPI: + app = FastAPI() + app.include_router(router) + container = _FakeChannelContainer() + + async def fake_get_channel(): + return container + + async def fake_auth(): + return True + + 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, + } + app.state.channel = container + return app + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + 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 + + 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 + + +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 + + +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, + }) + + 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_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"] + + 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(client): + resp = client.delete("/channel/bindings/999") + assert resp.status_code == 404 + + +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_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 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 new file mode 100644 index 00000000..4ed6ca9f --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/test_channel_status.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from yuxi.channel.interfaces.rest.router.channel_status import router +from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + + +class _FakeAdapter: + def __init__(self, name: str, healthy: bool = True): + self._name = name + self._healthy = healthy + self.capabilities = ChannelCapabilities( + media=True, + group=True, + dm=True, + streaming=False, + typing=True, + max_text_length=4096, + ) + + @property + def channel_type(self) -> str: + return self._name + + async def is_healthy(self) -> bool: + return self._healthy + + +class _FakeSessionRepo: + def __init__(self, counts: dict[str, int] | None = None): + self._counts = counts or {} + + async def count_active_sessions(self, channel_type: str) -> int: + return self._counts.get(channel_type, 0) + + +class _FakeWsManager: + def __init__(self, status: dict[str, bool] | None = None): + self.connections_status = status or {} + + +class _FakeAuthService: + async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]: + return True, "" + + +class _FakeChannelContainer: + def __init__(self): + self.adapters = { + "web": _FakeAdapter("web", healthy=True), + "feishu": _FakeAdapter("feishu", healthy=False), + } + self.session_repo = _FakeSessionRepo({"web": 5, "feishu": 0}) + self.ws_manager = _FakeWsManager({"web": True, "feishu": False}) + self.auth_service = _FakeAuthService() + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + 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 + + 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 + + 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 + + +def test_get_channel_status_no_ws_manager(): + app = FastAPI() + app.include_router(router) + + 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 + + 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["ws_connected"] is None + + +def test_get_channel_status_no_session_repo(): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + container.session_repo = 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/status") + assert resp.status_code == 200 + data = resp.json() + for status in data: + assert status["active_sessions"] == 0 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 new file mode 100644 index 00000000..f58243f7 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/test_config_reload.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from yuxi.channel.interfaces.rest.router.config_reload import router + + +class _FakeConfigService: + def __init__(self, updated: list[str] | None = None, config_hash: str | None = "abc123"): + self._updated = updated or ["auth_token"] + self._config_hash = config_hash + + async def reload(self) -> tuple[list[str], str | None]: + return self._updated, self._config_hash + + +class _FakeAuthService: + async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]: + return True, "" + + +class _FakeChannelContainer: + def __init__(self): + self.config_service = _FakeConfigService() + self.auth_service = _FakeAuthService() + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + 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 + + return TestClient(app) + + +def test_reload_config(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(): + 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"] == "" diff --git a/backend/test/unit/channel/interfaces/rest/router/test_health.py b/backend/test/unit/channel/interfaces/rest/router/test_health.py new file mode 100644 index 00000000..43cc83d8 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/test_health.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from yuxi.channel.interfaces.rest.router.health import router + + +class _FakeRedis: + def __init__(self, ping_ok: bool = True): + self._ping_ok = ping_ok + + async def ping(self): + if not self._ping_ok: + raise ConnectionError("redis down") + + +class _FakeWorkerPool: + def __init__(self, running: bool = True): + self._running = running + + @property + def is_running(self) -> bool: + return self._running + + +class _FakeAdapter: + def __init__(self, healthy: bool = True): + self._healthy = healthy + + async def is_healthy(self) -> bool: + return self._healthy + + +class _FakeWsManager: + def __init__(self, status: dict[str, bool] | None = None): + self.connections_status = status or {} + + +class _FakeStartupTracer: + def to_dict(self) -> list[dict]: + return [{"name": "init", "status": "ok", "duration_ms": 100}] + + +class _FakeChannelContainer: + def __init__(self): + self.redis = _FakeRedis() + self.worker_pool = _FakeWorkerPool() + self.adapters = { + "web": _FakeAdapter(healthy=True), + "feishu": _FakeAdapter(healthy=True), + } + self.ws_manager = _FakeWsManager() + self.startup_tracer = _FakeStartupTracer() + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + 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 + + return TestClient(app) + + +def test_liveness_check(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(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(): + 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(): + 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(): + 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(): + 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(): + 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(): + 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" diff --git a/backend/test/unit/channel/interfaces/rest/router/test_metrics.py b/backend/test/unit/channel/interfaces/rest/router/test_metrics.py new file mode 100644 index 00000000..af012ffe --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/test_metrics.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from yuxi.channel.interfaces.rest.router.metrics import router + + +class _FakeMetrics: + async def set_sse_connections(self, count: int) -> None: + pass + + +class _FakeSseEndpoint: + def __init__(self, count: int = 0): + self.connection_count = count + + +class _FakeChannelContainer: + def __init__(self): + self.sse_endpoint = _FakeSseEndpoint(count=5) + self.metrics = _FakeMetrics() + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + 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 + + return TestClient(app) + + +def test_metrics_endpoint(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(): + 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(): + 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 diff --git a/backend/test/unit/channel/interfaces/rest/router/test_registry.py b/backend/test/unit/channel/interfaces/rest/router/test_registry.py new file mode 100644 index 00000000..b198d310 --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/test_registry.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +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 + + +class _FakeContributor: + def __init__(self): + self._router = APIRouter() + + @property + def router(self): + return self._router + + +class _FakeAdapterWithContributor: + def __init__(self): + self._contributor = _FakeContributor() + + @property + def route_contributor(self): + return self._contributor + + +class _FakeAdapterWithoutContributor: + pass + + +class _FakeAdapterWithRouterAttr: + def __init__(self): + self.router = APIRouter() + + +def test_register_all_channel_routes(): + 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(): + 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(): + adapter = _FakeAdapterWithContributor() + contributor = _get_route_contributor(adapter) + assert contributor is not None + + +def test_get_route_contributor_without_contributor(): + adapter = _FakeAdapterWithoutContributor() + contributor = _get_route_contributor(adapter) + assert contributor is None + + +def test_get_route_contributor_with_router_attr(): + adapter = _FakeAdapterWithRouterAttr() + contributor = _get_route_contributor(adapter) + assert contributor is not None + assert hasattr(contributor, "router") diff --git a/backend/test/unit/channel/interfaces/rest/router/test_sse.py b/backend/test/unit/channel/interfaces/rest/router/test_sse.py new file mode 100644 index 00000000..0cb915cb --- /dev/null +++ b/backend/test/unit/channel/interfaces/rest/router/test_sse.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from yuxi.channel.interfaces.rest.router.sse import router + + +class _FakeSseEndpoint: + def __init__(self): + self.subscribed = [] + + 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") + + +class _FakeAuthService: + async def authenticate(self, auth_header: str, *, client_id: str = "unknown") -> tuple[bool, str]: + return True, "" + + +class _FakeChannelContainer: + def __init__(self): + self.sse_endpoint = _FakeSseEndpoint() + self.auth_service = _FakeAuthService() + + +@pytest.fixture +def client(): + app = FastAPI() + app.include_router(router) + + container = _FakeChannelContainer() + 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 + + return TestClient(app) + + +def test_subscribe_sse_valid_session_id(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(client): + resp = client.get("/channel/sse?session_id=invalid@session") + assert resp.status_code == 400 + + +def test_subscribe_sse_no_sse_endpoint(): + 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(client): + resp = client.get("/channel/sse") + assert resp.status_code == 422 diff --git a/backend/test/unit/channel/interfaces/sse/__init__.py b/backend/test/unit/channel/interfaces/sse/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/interfaces/sse/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..a42ee575 --- /dev/null +++ b/backend/test/unit/channel/interfaces/sse/test_endpoint.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +import asyncio +import pytest +from fastapi import Request + +from yuxi.channel.interfaces.sse.endpoint import SseEndpoint, _SseConnection + + +class _FakeRequest: + def __init__(self, disconnected: bool = False): + self._disconnected = disconnected + + async def is_disconnected(self) -> bool: + return self._disconnected + + +class _FakeMetrics: + def __init__(self): + self.sse_connections = 0 + + async def set_sse_connections(self, count: int) -> None: + self.sse_connections = count + + +@pytest.fixture +def endpoint(): + return SseEndpoint(max_connections=10, metrics=_FakeMetrics()) + + +@pytest.mark.asyncio +async def test_sse_endpoint_start_stop(endpoint): + await endpoint.start() + assert endpoint._cleanup_task is not None + await endpoint.stop() + assert endpoint._cleanup_task is None + + +@pytest.mark.asyncio +async def test_sse_endpoint_connection_count(endpoint): + assert endpoint.connection_count == 0 + endpoint._connections["test"] = [_SseConnection(session_id="test", queue=asyncio.Queue())] + assert endpoint.connection_count == 1 + + +@pytest.mark.asyncio +async def test_sse_endpoint_subscribe(endpoint): + await endpoint.start() + request = _FakeRequest() + response = await endpoint.subscribe("session-1", request) + assert response is not None + assert endpoint.connection_count == 1 + await endpoint.stop() + + +@pytest.mark.asyncio +async def test_sse_endpoint_subscribe_limit_reached(endpoint): + await endpoint.start() + endpoint._max_connections = 1 + request = _FakeRequest() + + # First connection + await endpoint.subscribe("session-1", request) + assert endpoint.connection_count == 1 + + # 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 + + await endpoint.stop() + + +@pytest.mark.asyncio +async def test_sse_endpoint_push_event(endpoint): + await endpoint.start() + queue = asyncio.Queue() + conn = _SseConnection(session_id="session-1", queue=queue) + endpoint._connections["session-1"] = [conn] + + delivered = await endpoint.push_event("session-1", {"type": "test", "data": "hello"}) + assert delivered is True + assert not queue.empty() + + await endpoint.stop() + + +@pytest.mark.asyncio +async def test_sse_endpoint_push_event_no_connections(endpoint): + await endpoint.start() + delivered = await endpoint.push_event("nonexistent", {"type": "test"}) + assert delivered is False + await endpoint.stop() + + +@pytest.mark.asyncio +async def test_sse_endpoint_broadcast_shutdown(endpoint): + await endpoint.start() + queue = asyncio.Queue() + conn = _SseConnection(session_id="session-1", queue=queue) + endpoint._connections["session-1"] = [conn] + + await endpoint.broadcast_shutdown() + assert not queue.empty() + msg = queue.get_nowait() + assert msg["type"] == "shutdown" + + await endpoint.stop() + + +@pytest.mark.asyncio +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] + + await endpoint._cleanup_stale() + assert "session-1" not in endpoint._connections + + await endpoint.stop() + + +@pytest.mark.asyncio +async def test_sse_endpoint_update_connection_count(endpoint): + metrics = _FakeMetrics() + ep = SseEndpoint(max_connections=10, metrics=metrics) + await ep.start() + + ep._connections["session-1"] = [_SseConnection(session_id="session-1", queue=asyncio.Queue())] + await ep._update_connection_count() + assert metrics.sse_connections == 1 + + await ep.stop() + + +@pytest.mark.asyncio +async def test_sse_endpoint_push_event_queue_full(endpoint): + await endpoint.start() + queue = asyncio.Queue(maxsize=1) + conn = _SseConnection(session_id="session-1", queue=queue) + endpoint._connections["session-1"] = [conn] + + # Fill the queue + queue.put_nowait({"type": "first"}) + + # This should not raise, but skip the full queue + delivered = await endpoint.push_event("session-1", {"type": "second"}) + assert delivered is True # At least one connection exists + + await endpoint.stop() diff --git a/backend/test/unit/channel/interfaces/websocket/__init__.py b/backend/test/unit/channel/interfaces/websocket/__init__.py new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/interfaces/websocket/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..a54816a6 --- /dev/null +++ b/backend/test/unit/channel/interfaces/websocket/test_manager.py @@ -0,0 +1,229 @@ +from __future__ import annotations + +import asyncio +import pytest +from uuid import uuid4 + +from yuxi.channel.interfaces.websocket.manager import WsConnectionManager +from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort +from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort +from yuxi.channel.domain.model.message.unified_message import UnifiedMessage +from yuxi.channel.domain.model.message.peer import Peer +from yuxi.channel.domain.model.shared.channel_type import ChannelType + + +class _FakeWsConnection(WsConnectionPort): + def __init__(self, channel_type: str, connected: bool = True): + self._channel_type = channel_type + self._connected = connected + self.started = False + self.stopped = False + + @property + def channel_type(self) -> str: + return self._channel_type + + @property + def is_connected(self) -> bool: + return self._connected + + async def start(self, loop, on_message) -> None: + self.started = True + + async def stop(self) -> None: + self.stopped = True + + +class _FakeAdapter(ChannelAdapterPort): + def __init__(self, channel_type: str): + self._channel_type = channel_type + + @property + def channel_type(self) -> str: + return self._channel_type + + @property + def ws_connection(self): + return None + + async def open(self) -> None: + pass + + async def close(self) -> None: + pass + + async def receive_message(self, raw: dict) -> UnifiedMessage: + return UnifiedMessage( + message_id="test", + channel_type=ChannelType.WEB, + sender=Peer(id="user", name="User"), + content="hello", + ) + + 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: + pass + + async def send_media(self, session_id: str, *, url: str, media_type: str, metadata: dict) -> bool: + return True + + async def is_healthy(self) -> bool: + return True + + @property + def capabilities(self): + from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities + return ChannelCapabilities() + + @classmethod + def get_default_config(cls) -> dict: + return {} + + +class _FakeInboundService: + def __init__(self): + self.submitted = [] + + async def submit(self, message, *, channel_type: str, trace_id: str = ""): + self.submitted.append({"message": message, "channel_type": channel_type, "trace_id": trace_id}) + + +class _FakeMetrics: + pass + + +@pytest.fixture +def manager(): + return WsConnectionManager(metrics=_FakeMetrics()) + + +@pytest.mark.asyncio +async def test_ws_manager_register(manager): + conn = _FakeWsConnection("feishu") + manager.register(conn) + assert "feishu" in manager._connections + + +@pytest.mark.asyncio +async def test_ws_manager_set_adapters(manager): + adapters = {"feishu": _FakeAdapter("feishu")} + manager.set_adapters(adapters) + assert manager._adapters == adapters + + +@pytest.mark.asyncio +async def test_ws_manager_start_all(manager): + conn = _FakeWsConnection("feishu") + manager.register(conn) + + inbound = _FakeInboundService() + loop = asyncio.get_event_loop() + + await manager.start_all(loop, inbound) + assert conn.started is True + + await manager.stop_all() + assert conn.stopped is True + + +@pytest.mark.asyncio +async def test_ws_manager_connections_status(manager): + conn1 = _FakeWsConnection("feishu", connected=True) + conn2 = _FakeWsConnection("dingtalk", connected=False) + manager.register(conn1) + manager.register(conn2) + + status = manager.connections_status + assert status["feishu"] is True + assert status["dingtalk"] is False + + +@pytest.mark.asyncio +async def test_ws_manager_on_message(manager): + adapter = _FakeAdapter("feishu") + manager.set_adapters({"feishu": adapter}) + + inbound = _FakeInboundService() + loop = asyncio.get_event_loop() + await manager.start_all(loop, inbound) + + raw = { + "channel_type": "feishu", + "header": {"event_id": "test-event-id"}, + "content": "hello", + } + await manager._on_message(raw) + + assert len(inbound.submitted) == 1 + assert inbound.submitted[0]["channel_type"] == "feishu" + assert inbound.submitted[0]["trace_id"] == "test-event-id" + + await manager.stop_all() + + +@pytest.mark.asyncio +async def test_ws_manager_on_message_no_adapter(manager): + manager.set_adapters({}) + + inbound = _FakeInboundService() + loop = asyncio.get_event_loop() + await manager.start_all(loop, inbound) + + raw = { + "channel_type": "unknown", + "header": {"event_id": "test-event-id"}, + } + await manager._on_message(raw) + + assert len(inbound.submitted) == 0 + + await manager.stop_all() + + +@pytest.mark.asyncio +async def test_ws_manager_on_message_receive_error(manager): + class _BadAdapter(_FakeAdapter): + async def receive_message(self, raw: dict) -> UnifiedMessage: + raise ValueError("parse error") + + manager.set_adapters({"feishu": _BadAdapter("feishu")}) + + inbound = _FakeInboundService() + loop = asyncio.get_event_loop() + await manager.start_all(loop, inbound) + + raw = { + "channel_type": "feishu", + "header": {"event_id": "test-event-id"}, + } + await manager._on_message(raw) + + assert len(inbound.submitted) == 0 + + await manager.stop_all() + + +@pytest.mark.asyncio +async def test_ws_manager_on_message_submit_error(manager): + class _BadInboundService: + async def submit(self, message, *, channel_type: str, trace_id: str = ""): + raise ValueError("submit error") + + adapter = _FakeAdapter("feishu") + manager.set_adapters({"feishu": adapter}) + + inbound = _BadInboundService() + loop = asyncio.get_event_loop() + await manager.start_all(loop, inbound) + + raw = { + "channel_type": "feishu", + "header": {"event_id": "test-event-id"}, + } + # Should not raise + await manager._on_message(raw) + + await manager.stop_all() diff --git a/backend/test/unit/channel/startup/__init__.py b/backend/test/unit/channel/startup/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/test/unit/channel/startup/test_agent_adapter.py b/backend/test/unit/channel/startup/test_agent_adapter.py new file mode 100644 index 00000000..2073cbe1 --- /dev/null +++ b/backend/test/unit/channel/startup/test_agent_adapter.py @@ -0,0 +1,67 @@ +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 new file mode 100644 index 00000000..e9c479b4 --- /dev/null +++ b/backend/test/unit/channel/startup/test_aho_corasick_matcher.py @@ -0,0 +1,45 @@ +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 new file mode 100644 index 00000000..a80df6c3 --- /dev/null +++ b/backend/test/unit/channel/startup/test_auth_service.py @@ -0,0 +1,98 @@ +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 new file mode 100644 index 00000000..aa50b623 --- /dev/null +++ b/backend/test/unit/channel/startup/test_binding_service.py @@ -0,0 +1,85 @@ +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 new file mode 100644 index 00000000..a685f4ed --- /dev/null +++ b/backend/test/unit/channel/startup/test_channel_binding.py @@ -0,0 +1,70 @@ +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 new file mode 100644 index 00000000..34ee60d0 --- /dev/null +++ b/backend/test/unit/channel/startup/test_channel_config.py @@ -0,0 +1,195 @@ +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 new file mode 100644 index 00000000..f932281e --- /dev/null +++ b/backend/test/unit/channel/startup/test_channel_container.py @@ -0,0 +1,131 @@ +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 new file mode 100644 index 00000000..416d170f --- /dev/null +++ b/backend/test/unit/channel/startup/test_channel_registry.py @@ -0,0 +1,55 @@ +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 new file mode 100644 index 00000000..5fcd317d --- /dev/null +++ b/backend/test/unit/channel/startup/test_channel_session.py @@ -0,0 +1,89 @@ +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 new file mode 100644 index 00000000..51191ec5 --- /dev/null +++ b/backend/test/unit/channel/startup/test_composite_content_filter.py @@ -0,0 +1,78 @@ +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 new file mode 100644 index 00000000..fdbbb371 --- /dev/null +++ b/backend/test/unit/channel/startup/test_config_service.py @@ -0,0 +1,81 @@ +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 new file mode 100644 index 00000000..aa0d181d --- /dev/null +++ b/backend/test/unit/channel/startup/test_container_factory.py @@ -0,0 +1,140 @@ +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 new file mode 100644 index 00000000..18e304ab --- /dev/null +++ b/backend/test/unit/channel/startup/test_delivery_service.py @@ -0,0 +1,146 @@ +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 new file mode 100644 index 00000000..39d12d20 --- /dev/null +++ b/backend/test/unit/channel/startup/test_dingtalk_adapter.py @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000..37f4a112 --- /dev/null +++ b/backend/test/unit/channel/startup/test_dispatch_service.py @@ -0,0 +1,130 @@ +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 new file mode 100644 index 00000000..16309ef8 --- /dev/null +++ b/backend/test/unit/channel/startup/test_domain_events.py @@ -0,0 +1,105 @@ +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 new file mode 100644 index 00000000..d5554ed2 --- /dev/null +++ b/backend/test/unit/channel/startup/test_domain_exceptions.py @@ -0,0 +1,68 @@ +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 new file mode 100644 index 00000000..a585ad10 --- /dev/null +++ b/backend/test/unit/channel/startup/test_feishu_adapter.py @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000..f56d2868 --- /dev/null +++ b/backend/test/unit/channel/startup/test_hmac_signature_verifier.py @@ -0,0 +1,40 @@ +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 new file mode 100644 index 00000000..aedd0f47 --- /dev/null +++ b/backend/test/unit/channel/startup/test_hooks_adapter.py @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000..3c7df5ab --- /dev/null +++ b/backend/test/unit/channel/startup/test_inbound_service.py @@ -0,0 +1,113 @@ +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 new file mode 100644 index 00000000..5db4affb --- /dev/null +++ b/backend/test/unit/channel/startup/test_llm_content_filter.py @@ -0,0 +1,44 @@ +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 new file mode 100644 index 00000000..5d8f9b98 --- /dev/null +++ b/backend/test/unit/channel/startup/test_message_context.py @@ -0,0 +1,67 @@ +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 new file mode 100644 index 00000000..53df2033 --- /dev/null +++ b/backend/test/unit/channel/startup/test_middlewares.py @@ -0,0 +1,367 @@ +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 new file mode 100644 index 00000000..087f260c --- /dev/null +++ b/backend/test/unit/channel/startup/test_outbox_retry.py @@ -0,0 +1,116 @@ +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 new file mode 100644 index 00000000..b7c8a6df --- /dev/null +++ b/backend/test/unit/channel/startup/test_pipeline.py @@ -0,0 +1,87 @@ +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 new file mode 100644 index 00000000..d58dc3e1 --- /dev/null +++ b/backend/test/unit/channel/startup/test_pipeline_builder.py @@ -0,0 +1,116 @@ +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 new file mode 100644 index 00000000..cb20889b --- /dev/null +++ b/backend/test/unit/channel/startup/test_prometheus_metrics.py @@ -0,0 +1,737 @@ +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 new file mode 100644 index 00000000..24e705d0 --- /dev/null +++ b/backend/test/unit/channel/startup/test_redis_bot_loop_guard.py @@ -0,0 +1,46 @@ +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 new file mode 100644 index 00000000..812ee8f4 --- /dev/null +++ b/backend/test/unit/channel/startup/test_redis_cache.py @@ -0,0 +1,72 @@ +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 new file mode 100644 index 00000000..a5f70784 --- /dev/null +++ b/backend/test/unit/channel/startup/test_redis_circuit_breaker.py @@ -0,0 +1,64 @@ +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 new file mode 100644 index 00000000..a7e56c3d --- /dev/null +++ b/backend/test/unit/channel/startup/test_redis_config_reload.py @@ -0,0 +1,52 @@ +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 new file mode 100644 index 00000000..dd8b2a23 --- /dev/null +++ b/backend/test/unit/channel/startup/test_redis_content_filter.py @@ -0,0 +1,52 @@ +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 new file mode 100644 index 00000000..1acb341e --- /dev/null +++ b/backend/test/unit/channel/startup/test_redis_pubsub.py @@ -0,0 +1,89 @@ +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 new file mode 100644 index 00000000..86b7ca4a --- /dev/null +++ b/backend/test/unit/channel/startup/test_redis_rate_limiter.py @@ -0,0 +1,63 @@ +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 new file mode 100644 index 00000000..4ca69bf3 --- /dev/null +++ b/backend/test/unit/channel/startup/test_redis_stream_queue.py @@ -0,0 +1,89 @@ +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 new file mode 100644 index 00000000..5f2c9ae7 --- /dev/null +++ b/backend/test/unit/channel/startup/test_session_factory.py @@ -0,0 +1,100 @@ +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 new file mode 100644 index 00000000..a0ebb7a3 --- /dev/null +++ b/backend/test/unit/channel/startup/test_session_resolver.py @@ -0,0 +1,187 @@ +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 new file mode 100644 index 00000000..5967d4db --- /dev/null +++ b/backend/test/unit/channel/startup/test_setup_channel.py @@ -0,0 +1,47 @@ +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 new file mode 100644 index 00000000..e1a05114 --- /dev/null +++ b/backend/test/unit/channel/startup/test_sse_endpoint.py @@ -0,0 +1,71 @@ +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 new file mode 100644 index 00000000..accbe2e9 --- /dev/null +++ b/backend/test/unit/channel/startup/test_startup_module.py @@ -0,0 +1,76 @@ +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 new file mode 100644 index 00000000..c47604c4 --- /dev/null +++ b/backend/test/unit/channel/startup/test_startup_tracer.py @@ -0,0 +1,62 @@ +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 new file mode 100644 index 00000000..f8d98371 --- /dev/null +++ b/backend/test/unit/channel/startup/test_unified_message.py @@ -0,0 +1,107 @@ +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 new file mode 100644 index 00000000..0cc78efc --- /dev/null +++ b/backend/test/unit/channel/startup/test_web_adapter.py @@ -0,0 +1,74 @@ +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 new file mode 100644 index 00000000..8d4a9273 --- /dev/null +++ b/backend/test/unit/channel/startup/test_worker_pool.py @@ -0,0 +1,78 @@ +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 new file mode 100644 index 00000000..299afa20 --- /dev/null +++ b/backend/test/unit/channel/startup/test_ws_manager.py @@ -0,0 +1,87 @@ +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 new file mode 100644 index 00000000..5f282702 --- /dev/null +++ b/backend/test/unit/channel/worker/__init__.py @@ -0,0 +1 @@ + \ 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 new file mode 100644 index 00000000..4c275b4d --- /dev/null +++ b/backend/test/unit/channel/worker/test_outbox_retry.py @@ -0,0 +1,254 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +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.worker.outbox_retry import OutboxRetryWorker, _OUTBOX_NOTIFY_CHANNEL + + +@pytest.fixture +def mock_outbox_repo(): + repo = MagicMock() + repo.fetch_pending = AsyncMock(return_value=[]) + repo.mark_sent = AsyncMock(return_value=True) + repo.mark_dead = AsyncMock(return_value=True) + repo.mark_retrying = AsyncMock(return_value=None) + return repo + + +@pytest.fixture +def mock_adapter(): + adapter = MagicMock() + adapter.send_message = AsyncMock(return_value=SendResult(success=True, error="")) + adapter.channel_type = "web" + return adapter + + +@pytest.fixture +def mock_metrics(): + metrics = MagicMock() + metrics.record_outbox_retry_total = AsyncMock() + return metrics + + +@pytest.fixture +def mock_redis(): + redis = MagicMock() + pubsub = MagicMock() + pubsub.subscribe = AsyncMock() + pubsub.unsubscribe = AsyncMock() + pubsub.aclose = AsyncMock() + pubsub.listen = AsyncMock(return_value=asyncio.sleep(0)) + redis.pubsub = MagicMock(return_value=pubsub) + return redis + + +@pytest.fixture +def outbox_worker(mock_outbox_repo, mock_adapter, mock_metrics): + return OutboxRetryWorker( + outbox_repo=mock_outbox_repo, + adapters={"web": mock_adapter}, + redis=None, + metrics=mock_metrics, + poll_interval=0.1, + ) + + +@pytest.fixture +def outbox_entry(): + return OutboxEntry( + id=1, + message_id="msg-1", + session_id="sess-1", + channel_type="web", + content="hello", + status="pending", + retry_count=0, + max_retries=3, + next_retry_at=None, + last_error=None, + trace_id="trace-1", + extra_metadata=None, + ) + + +def _make_fetch_side_effect(entries): + call_count = 0 + + async def fetch_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return entries + return [] + + return fetch_side_effect + + +@pytest.mark.unit +async def test_outbox_retry_start_stop(outbox_worker): + await outbox_worker.start() + assert outbox_worker._running is True + assert outbox_worker._task is not None + + await outbox_worker.stop() + assert outbox_worker._running is False + assert outbox_worker._task is None + + +@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])) + + await outbox_worker.start() + await asyncio.sleep(0.2) + await outbox_worker.stop() + + mock_adapter.send_message.assert_awaited_once_with( + "sess-1", + "hello", + channel_type="web", + metadata={"trace_id": "trace-1"}, + ) + mock_outbox_repo.mark_sent.assert_awaited_once_with(1) + mock_metrics.record_outbox_retry_total.assert_awaited_once_with("web", "success") + + +@pytest.mark.unit +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])) + + await outbox_worker.start() + await asyncio.sleep(0.2) + await outbox_worker.stop() + + mock_outbox_repo.mark_retrying.assert_awaited_once_with(1, last_error="network error") + mock_metrics.record_outbox_retry_total.assert_awaited_once_with("web", "retrying") + + +@pytest.mark.unit +async def test_process_pending_failure_then_dead(outbox_worker, mock_outbox_repo, mock_adapter, mock_metrics): + entry = OutboxEntry( + id=2, + message_id="msg-2", + session_id="sess-2", + channel_type="web", + content="hello", + status="pending", + retry_count=2, + max_retries=3, + next_retry_at=None, + last_error=None, + trace_id="trace-2", + 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])) + + 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_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])) + + await outbox_worker.start() + await asyncio.sleep(0.2) + await outbox_worker.stop() + + mock_adapter.send_message.assert_not_awaited() + mock_outbox_repo.mark_sent.assert_not_awaited() + + +@pytest.mark.unit +async def test_process_pending_no_metrics(mock_outbox_repo, mock_adapter, outbox_entry): + worker = OutboxRetryWorker( + outbox_repo=mock_outbox_repo, + adapters={"web": mock_adapter}, + redis=None, + metrics=None, + poll_interval=0.1, + ) + mock_outbox_repo.fetch_pending = AsyncMock(side_effect=_make_fetch_side_effect([outbox_entry])) + + await worker.start() + await asyncio.sleep(0.2) + await worker.stop() + + mock_adapter.send_message.assert_awaited_once() + mock_outbox_repo.mark_sent.assert_awaited_once_with(1) + + +@pytest.mark.unit +async def test_listen_notifications(mock_redis, mock_outbox_repo, mock_adapter): + async def _anext_side_effect(items): + for item in items: + yield item + while True: + await asyncio.sleep(10) + + pubsub = mock_redis.pubsub.return_value + pubsub.listen = _anext_side_effect([{"type": "message", "data": "1"}]) + + worker = OutboxRetryWorker( + outbox_repo=mock_outbox_repo, + adapters={"web": mock_adapter}, + redis=mock_redis, + metrics=None, + poll_interval=0.5, + ) + + await worker.start() + await asyncio.sleep(0.2) + await worker.stop() + + mock_redis.pubsub.assert_called_once() + pubsub.subscribe.assert_awaited_once_with(_OUTBOX_NOTIFY_CHANNEL) + + +@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")) + + 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") diff --git a/backend/test/unit/channel/worker/test_pool.py b/backend/test/unit/channel/worker/test_pool.py new file mode 100644 index 00000000..8bb29c6c --- /dev/null +++ b/backend/test/unit/channel/worker/test_pool.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.worker.pool import WorkerPool, WorkerPoolConfig + + +@pytest.fixture +def mock_queue_port(): + port = MagicMock() + port.ensure_group = AsyncMock() + port.dequeue = AsyncMock(return_value=[]) + port.ack = AsyncMock() + return port + + +@pytest.fixture +def worker_pool(mock_queue_port): + config = WorkerPoolConfig(num_workers=2, max_concurrent=5, poll_timeout_ms=1000) + return WorkerPool( + queue_port=mock_queue_port, + dispatch_fn=AsyncMock(), + config=config, + session_router=True, + ) + + +@pytest.fixture +def legacy_worker_pool(mock_queue_port): + config = WorkerPoolConfig(num_workers=2, max_concurrent=5, poll_timeout_ms=1000) + return WorkerPool( + queue_port=mock_queue_port, + dispatch_fn=AsyncMock(), + config=config, + session_router=False, + ) + + +@pytest.mark.unit +async def test_worker_pool_start_stop(worker_pool, mock_queue_port): + await worker_pool.start() + assert worker_pool.is_running is True + assert len(worker_pool._tasks) == 3 + mock_queue_port.ensure_group.assert_awaited_once() + + await worker_pool.stop() + assert worker_pool.is_running is False + assert len(worker_pool._tasks) == 0 + + +@pytest.mark.unit +async def test_worker_pool_start_without_session_router(legacy_worker_pool, mock_queue_port): + await legacy_worker_pool.start() + assert legacy_worker_pool.is_running is True + assert len(legacy_worker_pool._tasks) == 2 + + await legacy_worker_pool.stop() + assert legacy_worker_pool.is_running is False + + +@pytest.mark.unit +async def test_worker_pool_start_without_ensure_group(mock_queue_port): + del mock_queue_port.ensure_group + config = WorkerPoolConfig(num_workers=1, max_concurrent=5, poll_timeout_ms=1000) + pool = WorkerPool( + queue_port=mock_queue_port, + dispatch_fn=AsyncMock(), + config=config, + session_router=True, + ) + await pool.start() + assert pool.is_running is True + await pool.stop() + + +@pytest.mark.unit +async def test_dispatch_loop_routes_messages(worker_pool, mock_queue_port): + messages = [ + {"session_id": "sess-1", "content": "msg1", "_stream_id": "stream-1"}, + {"session_id": "sess-2", "content": "msg2", "_stream_id": "stream-2"}, + ] + call_count = 0 + + async def dequeue_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return messages + return [] + + mock_queue_port.dequeue = AsyncMock(side_effect=dequeue_side_effect) + + await worker_pool.start() + await asyncio.sleep(0.2) + await worker_pool.stop() + + assert mock_queue_port.dequeue.await_count >= 1 + + +@pytest.mark.unit +async def test_worker_loop_dispatches_and_acks(worker_pool, mock_queue_port): + msg = {"session_id": "sess-1", "content": "hello", "_stream_id": "stream-1"} + call_count = 0 + + async def dequeue_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return [msg] + return [] + + mock_queue_port.dequeue = AsyncMock(side_effect=dequeue_side_effect) + + await worker_pool.start() + await asyncio.sleep(0.3) + await worker_pool.stop() + + worker_pool._dispatch.assert_awaited() + mock_queue_port.ack.assert_awaited_with("stream-1") + + +@pytest.mark.unit +async def test_worker_loop_handles_dispatch_error(worker_pool, mock_queue_port): + worker_pool._dispatch = AsyncMock(side_effect=Exception("dispatch failed")) + msg = {"session_id": "sess-1", "content": "hello", "_stream_id": "stream-1"} + call_count = 0 + + async def dequeue_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return [msg] + return [] + + mock_queue_port.dequeue = AsyncMock(side_effect=dequeue_side_effect) + + await worker_pool.start() + await asyncio.sleep(0.3) + await worker_pool.stop() + + worker_pool._dispatch.assert_awaited() + + +@pytest.mark.unit +async def test_route_with_session_id(worker_pool): + for i in range(worker_pool._config.num_workers): + worker_pool._session_queues.append(asyncio.Queue(maxsize=worker_pool._config.max_concurrent)) + + msg = {"session_id": "sess-abc", "content": "hello"} + idx = hash("sess-abc") % worker_pool._config.num_workers + queue = worker_pool._session_queues[idx] + + await worker_pool._route(msg) + assert queue.qsize() == 1 + assert queue.get_nowait() == msg + + +@pytest.mark.unit +async def test_route_without_session_id(worker_pool): + for i in range(worker_pool._config.num_workers): + worker_pool._session_queues.append(asyncio.Queue(maxsize=worker_pool._config.max_concurrent)) + + msg = {"content": "hello"} + initial_rr = worker_pool._rr_index + + await worker_pool._route(msg) + assert worker_pool._rr_index == initial_rr + 1 + assert worker_pool._session_queues[initial_rr % worker_pool._config.num_workers].qsize() == 1 + + +@pytest.mark.unit +async def test_legacy_worker_loop(worker_pool, mock_queue_port): + worker_pool._session_router = False + msg = {"content": "legacy", "_stream_id": "stream-legacy"} + call_count = 0 + + async def dequeue_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return [msg] + return [] + + mock_queue_port.dequeue = AsyncMock(side_effect=dequeue_side_effect) + + await worker_pool.start() + await asyncio.sleep(0.3) + await worker_pool.stop() + + worker_pool._dispatch.assert_awaited() + mock_queue_port.ack.assert_awaited_with("stream-legacy") + + +@pytest.mark.unit +async def test_worker_loop_without_stream_id(worker_pool, mock_queue_port): + msg = {"session_id": "sess-1", "content": "no stream"} + call_count = 0 + + async def dequeue_side_effect(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return [msg] + return [] + + mock_queue_port.dequeue = AsyncMock(side_effect=dequeue_side_effect) + + await worker_pool.start() + await asyncio.sleep(0.3) + await worker_pool.stop() + + worker_pool._dispatch.assert_awaited() + mock_queue_port.ack.assert_not_awaited() + + +@pytest.mark.unit +async def test_dispatch_loop_error_handling(worker_pool, mock_queue_port): + mock_queue_port.dequeue = AsyncMock(side_effect=Exception("dequeue error")) + + await worker_pool.start() + await asyncio.sleep(0.3) + await worker_pool.stop() + + assert mock_queue_port.dequeue.await_count >= 1 diff --git a/backend/test/unit/channel/worker/test_session_factory.py b/backend/test/unit/channel/worker/test_session_factory.py new file mode 100644 index 00000000..24650c8a --- /dev/null +++ b/backend/test/unit/channel/worker/test_session_factory.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.channel.worker.session_factory import WorkerSessionFactory + + +@pytest.fixture +def mock_pg_manager(): + manager = MagicMock() + mock_session = AsyncMock() + context_manager = MagicMock() + context_manager.__aenter__ = AsyncMock(return_value=mock_session) + context_manager.__aexit__ = AsyncMock(return_value=False) + manager.get_async_session_context = MagicMock(return_value=context_manager) + return manager, mock_session + + +@pytest.mark.unit +async def test_create_session_yields_session(mock_pg_manager): + manager, mock_session = mock_pg_manager + factory = WorkerSessionFactory(manager) + + async with factory.create_session() as session: + assert session is mock_session + + +@pytest.mark.unit +async def test_create_session_uses_pg_manager(mock_pg_manager): + manager, mock_session = mock_pg_manager + factory = WorkerSessionFactory(manager) + + async with factory.create_session() as session: + pass + + manager.get_async_session_context.assert_called_once()