ForcePilot/backend/test/unit/channels/application/pipeline/test_base.py
Kris b4ea53a447 test: 修复多单元测试用例,完善测试覆盖与校验逻辑
1.  修复钉钉客户端API调用参数格式,将unionid从URL路径移至params
2.  补充服务账号mock的active状态,完善权限校验测试前置条件
3.  更新钉钉消息发送缓存计数断言,修正缓存键数量预期
4.  补充分类阶段测试的日志mock与新增字段校验
5.  新增事务端口的get_session方法测试,完善事务上下文接口覆盖
6.  修正代理访问配置默认权限等级为global,更新对应测试用例
7.  重构权限阶段未知请求处理逻辑,改为失败关闭而非默认管理员权限
8.  更新ack记录失败处理逻辑,改为抛出异常而非仅告警
9.  新增能力校验阶段异常降级测试用例
10. 重构发件箱持久化阶段测试,移除冗余查询并验证结果
11. 新增消息撤回测试的会话ID缓存获取逻辑
12. 修复映射器测试的账号ID匹配逻辑
13. 新增输入幂等阶段的缓存锁依赖,完善并发测试支持
14. 补充围栏代际回滚方法的完整测试覆盖
15. 新增路由匹配注册表的原子替换测试用例
16. 新增会话统计接口的截断状态与总数统计测试
17. 修正配置schema统计计数,更新热更新字段与总条目数
18. 补充调度任务处理器的缓存端口依赖,完善健康检查与锁机制测试
2026-07-09 13:53:26 +08:00

635 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""yuxi.channels.application.pipeline.base 单元测试。
覆盖 ``Pipeline``
- ``run(ctx)``:正常执行所有 stage / 某 stage 失败时停止后续 stage
- ``_validateCompensateReferences()``:补偿引用校验(指向不存在的 stage 报错)
- ``_runCompensate()``:执行补偿逻辑
- ``_runCleanup()``协程取消清理C-O2
- ``_findStage()``:按名称查找 stage
- ``_logStageExecution()``:日志记录
"""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock
import pytest
from yuxi.channels.application.pipeline.base import Pipeline
from yuxi.channels.contract.errors import PipelineConfigError, ValidationError
from yuxi.channels.contract.plugin.extension_point import FailureStrategy
pytestmark = pytest.mark.unit
# ─── helper ────────────────────────────────────────────────────────────────
class _FakeStage:
"""满足 ``AppStage`` Protocol 的可编排测试 stage。
``process`` 默认返回 True可通过 ``result`` / ``raise_error`` 覆盖行为。
``raise_error`` 接受 ``BaseException`` 以支持 ``asyncio.CancelledError``
C-O2 清理测试)。
"""
def __init__(
self,
stage_id: str,
*,
result: bool = True,
raise_error: BaseException | None = None,
compensate: str | None = None,
failure: FailureStrategy = FailureStrategy.TERMINATE,
condition: Callable[[Any], bool] | None = None,
cleanup: bool = False,
) -> None:
self.id = stage_id
self.reads: tuple[str, ...] = ()
self.writes: tuple[str, ...] = ()
self.idempotent = True
self.thread_safe = True
self.failure = failure
self.compensate = compensate
self.cleanup = cleanup
self.condition = condition
self._result = result
self._raise_error = raise_error
self.process = AsyncMock(return_value=self._result)
if raise_error is not None:
self.process = AsyncMock(side_effect=raise_error)
class _FakeCtx:
"""管道上下文桩,携带 ``trace_id`` 与降级字段。
``streaming_started`` / ``typing_started`` 用于 C-O2 清理逻辑测试。
"""
def __init__(
self,
trace_id: str = "trace-001",
*,
streaming_started: bool = False,
typing_started: bool = False,
) -> None:
self.trace_id = trace_id
self.degraded = False
self.degraded_reason = None
self.streaming_started = streaming_started
self.typing_started = typing_started
class _FakeTracer:
"""``TracerPort`` 桩,记录 ``startSpan`` / ``endSpan`` 调用以便断言。"""
def __init__(self) -> None:
self.start_calls: list[tuple[str, str | None]] = []
self.end_calls: list[tuple[str, str]] = []
self._seq = 0
async def startSpan(self, name, trace_id=None, parent_span_id=None): # noqa: ANN001
self._seq += 1
span = SimpleNamespace(name=name, trace_id=trace_id, span_id=f"span-{self._seq}")
self.start_calls.append((name, trace_id))
return span
async def endSpan(self, span, status="ok"): # noqa: ANN001
self.end_calls.append((span.name, status))
async def getCurrentTrace(self):
return None
# ─── run ───────────────────────────────────────────────────────────────────
@pytest.mark.unit
class TestPipelineRun:
"""``Pipeline.run`` 顺序执行与失败策略。"""
@pytest.mark.asyncio
async def test_run_executes_all_stages_when_all_succeed(self):
# Arrange
s1 = _FakeStage("s1")
s2 = _FakeStage("s2")
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is True
assert err is None
s1.process.assert_awaited_once_with(ctx)
s2.process.assert_awaited_once_with(ctx)
@pytest.mark.asyncio
async def test_run_stops_subsequent_stages_on_terminate_failure(self):
# Arrange
s1 = _FakeStage("s1", result=False, failure=FailureStrategy.TERMINATE)
s2 = _FakeStage("s2")
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is False
s1.process.assert_awaited_once_with(ctx)
s2.process.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_skip_strategy_continues_next_stage(self):
# Arrange
s1 = _FakeStage("s1", result=False, failure=FailureStrategy.SKIP)
s2 = _FakeStage("s2", result=True)
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is True
assert err is None
s2.process.assert_awaited_once_with(ctx)
@pytest.mark.asyncio
async def test_run_degrade_strategy_marks_context_and_continues(self):
# Arrange
s1 = _FakeStage("s1", result=False, failure=FailureStrategy.DEGRADE)
s2 = _FakeStage("s2", result=True)
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is True
assert ctx.degraded is True
@pytest.mark.asyncio
async def test_run_condition_false_skips_stage(self):
# Arrange
s1 = _FakeStage("s1", condition=lambda _ctx: False)
s2 = _FakeStage("s2")
pipeline = Pipeline(name="test", stages=[s1, s2])
ctx = _FakeCtx()
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is True
s1.process.assert_not_awaited()
s2.process.assert_awaited_once_with(ctx)
@pytest.mark.asyncio
async def test_run_native_exception_translated_to_internal_error(self):
# Arrange
native = RuntimeError("boom")
s1 = _FakeStage("s1", raise_error=native, failure=FailureStrategy.TERMINATE)
pipeline = Pipeline(name="test", stages=[s1])
ctx = _FakeCtx(trace_id="trace-native")
# Act
ok, err = await pipeline.run(ctx)
# Assert
assert ok is False
assert err is not None
assert err.error_code == "INTERNAL"
assert err.trace_id == "trace-native"
assert err.cause is native
# ─── _runCleanupC-O2 协程取消清理)──────────────────────────────────────
@pytest.mark.unit
class TestPipelineRunCleanup:
"""``Pipeline.run`` 的 finally 清理逻辑C-O2
覆盖协程取消时清理阶段(``cleanup=True``的执行CancelledError 重新
抛出、清理异常不掩盖原始取消、无流式资源时跳过清理。
"""
@pytest.mark.asyncio
async def test_cancelled_error_triggers_cleanup_and_reraises(self, fake_logger):
# Arrange — stream-chunk 阶段被取消typing-stopcleanup=True应在
# finally 中被调用释放资源CancelledError 在清理后重新抛出
stream_chunk = _FakeStage(
"stream-chunk",
raise_error=asyncio.CancelledError(),
failure=FailureStrategy.TERMINATE,
)
typing_stop = _FakeStage(
"typing-stop",
cleanup=True,
condition=lambda ctx: ctx.streaming_started or ctx.typing_started,
)
pipeline = Pipeline(
name="outbound",
stages=[stream_chunk, typing_stop],
logger=fake_logger,
)
ctx = _FakeCtx(trace_id="trace-cancel", streaming_started=True)
# Act / Assert — CancelledError 重新抛出,清理阶段被执行
with pytest.raises(asyncio.CancelledError):
await pipeline.run(ctx)
stream_chunk.process.assert_awaited_once_with(ctx)
typing_stop.process.assert_awaited_once_with(ctx)
@pytest.mark.asyncio
async def test_cleanup_exception_does_not_mask_cancelled_error(self, fake_logger):
# Arrange — 清理阶段自身抛异常时仅 warn 日志,不掩盖原始 CancelledError
stream_chunk = _FakeStage(
"stream-chunk",
raise_error=asyncio.CancelledError(),
failure=FailureStrategy.TERMINATE,
)
typing_stop = _FakeStage(
"typing-stop",
raise_error=RuntimeError("cleanup boom"),
cleanup=True,
condition=lambda ctx: ctx.streaming_started or ctx.typing_started,
)
pipeline = Pipeline(
name="outbound",
stages=[stream_chunk, typing_stop],
logger=fake_logger,
)
ctx = _FakeCtx(trace_id="trace-mask", streaming_started=True)
# Act / Assert — 抛出的是 CancelledError 而非清理阶段的 RuntimeError
with pytest.raises(asyncio.CancelledError):
await pipeline.run(ctx)
typing_stop.process.assert_awaited_once_with(ctx)
fake_logger.warn.assert_awaited()
@pytest.mark.asyncio
async def test_no_cleanup_when_streaming_not_started(self, fake_logger):
# Arrange — streaming_started=False 且 typing_started=False 时,
# 即使协程被取消,清理阶段也不执行(无流式资源需释放)
stream_chunk = _FakeStage(
"stream-chunk",
raise_error=asyncio.CancelledError(),
failure=FailureStrategy.TERMINATE,
)
typing_stop = _FakeStage(
"typing-stop",
cleanup=True,
condition=lambda ctx: ctx.streaming_started or ctx.typing_started,
)
pipeline = Pipeline(
name="outbound",
stages=[stream_chunk, typing_stop],
logger=fake_logger,
)
ctx = _FakeCtx(trace_id="trace-no-stream", streaming_started=False, typing_started=False)
# Act / Assert — CancelledError 重新抛出,但清理阶段未被调用
with pytest.raises(asyncio.CancelledError):
await pipeline.run(ctx)
typing_stop.process.assert_not_awaited()
@pytest.mark.asyncio
async def test_normal_completion_skips_cleanup_when_typing_stop_already_ran(self, fake_logger):
# Arrange — 正常完成时 typing-stop 已在循环中执行并重置标志,
# finally 清理阶段 condition 不满足,不会重复执行
typing_stop = _FakeStage(
"typing-stop",
cleanup=True,
failure=FailureStrategy.SKIP,
condition=lambda ctx: ctx.streaming_started or ctx.typing_started,
)
# 模拟 typing-stop 正常执行后重置标志:用 side_effect 重置
async def _reset_flags(ctx):
ctx.streaming_started = False
ctx.typing_started = False
return True
typing_stop.process = AsyncMock(side_effect=_reset_flags)
pipeline = Pipeline(
name="outbound",
stages=[typing_stop],
logger=fake_logger,
)
ctx = _FakeCtx(trace_id="trace-normal", streaming_started=True, typing_started=True)
# Act
ok, err = await pipeline.run(ctx)
# Assert — 正常完成typing-stop 在循环中执行 1 次finally 不再重复执行
assert ok is True
assert err is None
assert typing_stop.process.await_count == 1
# ─── _validateCompensateReferences ─────────────────────────────────────────
@pytest.mark.unit
class TestValidateCompensateReferences:
"""``_validateCompensateReferences`` 构造期补偿引用校验。"""
def test_valid_compensate_reference_does_not_raise(self):
# Arrange
compensate_stage = _FakeStage("rollback")
s1 = _FakeStage("s1", compensate="rollback")
# Act
pipeline = Pipeline(name="test", stages=[s1, compensate_stage])
# Assert
assert pipeline.stages == [s1, compensate_stage]
def test_unknown_compensate_reference_raises_pipeline_config_error(self):
# Arrange
s1 = _FakeStage("s1", compensate="nonexistent")
# Act / Assert
with pytest.raises(PipelineConfigError) as exc_info:
Pipeline(name="test", stages=[s1])
assert exc_info.value.pipeline == "test"
assert exc_info.value.stage_id == "s1"
assert exc_info.value.compensate == "nonexistent"
def test_none_compensate_does_not_raise(self):
# Arrange
s1 = _FakeStage("s1", compensate=None)
s2 = _FakeStage("s2", compensate=None)
# Act
pipeline = Pipeline(name="test", stages=[s1, s2])
# Assert
assert len(pipeline.stages) == 2
# ─── _runCompensate ────────────────────────────────────────────────────────
@pytest.mark.unit
class TestRunCompensate:
"""``_runCompensate`` 补偿执行逻辑。"""
@pytest.mark.asyncio
async def test_run_compensate_executes_compensate_stage_and_returns_original_error(self):
# Arrange
compensate_stage = _FakeStage("rollback")
s1 = _FakeStage("s1", compensate="rollback", failure=FailureStrategy.COMPENSATE)
pipeline = Pipeline(name="test", stages=[s1, compensate_stage])
original_err = ValidationError("field", "boom", trace_id="trace-001")
# Act
result = await pipeline._runCompensate(_FakeCtx(), "rollback", original_err)
# Assert
assert result is original_err
compensate_stage.process.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_compensate_returns_original_error_when_name_is_none(self):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")])
original_err = ValidationError("field", "boom")
# Act
result = await pipeline._runCompensate(_FakeCtx(), None, original_err)
# Assert
assert result is original_err
@pytest.mark.asyncio
async def test_run_compensate_returns_original_error_when_stage_not_found(self):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")])
original_err = ValidationError("field", "boom")
# Act
result = await pipeline._runCompensate(_FakeCtx(), "missing", original_err)
# Assert
assert result is original_err
@pytest.mark.asyncio
async def test_run_compensate_swallows_compensate_exception_and_logs(self, fake_logger):
# Arrange
compensate_stage = _FakeStage("rollback", raise_error=RuntimeError("compensate failed"))
s1 = _FakeStage("s1", compensate="rollback")
pipeline = Pipeline(name="test", stages=[s1, compensate_stage], logger=fake_logger)
original_err = ValidationError("field", "boom", trace_id="trace-001")
# Act
result = await pipeline._runCompensate(_FakeCtx(), "rollback", original_err)
# Assert
assert result is original_err
fake_logger.error.assert_awaited_once()
# ─── _findStage ────────────────────────────────────────────────────────────
@pytest.mark.unit
class TestFindStage:
"""``_findStage`` 按 id 查找阶段。"""
def test_find_stage_returns_matching_stage(self):
# Arrange
target = _FakeStage("target")
other = _FakeStage("other")
pipeline = Pipeline(name="test", stages=[other, target])
# Act
result = pipeline._findStage("target")
# Assert
assert result is target
def test_find_stage_returns_none_when_not_found(self):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")])
# Act
result = pipeline._findStage("missing")
# Assert
assert result is None
# ─── _logStageExecution ────────────────────────────────────────────────────
@pytest.mark.unit
class TestLogStageExecution:
"""``_logStageExecution`` 结构化日志输出。"""
@pytest.mark.asyncio
async def test_log_stage_execution_calls_info_on_success(self, fake_logger):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")], logger=fake_logger)
ctx = _FakeCtx(trace_id="trace-log")
# Act
await pipeline._logStageExecution(ctx, "s1", 12.5, True, None)
# Assert
fake_logger.info.assert_awaited_once()
call_args = fake_logger.info.call_args
assert call_args.kwargs["trace_id"] == "trace-log"
assert call_args.kwargs["stage_id"] == "s1"
assert call_args.kwargs["status"] == "ok"
assert call_args.kwargs["duration_ms"] == 12.5
@pytest.mark.asyncio
async def test_log_stage_execution_calls_warn_on_failure(self, fake_logger):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")], logger=fake_logger)
ctx = _FakeCtx(trace_id="trace-fail")
err = ValidationError("field", "boom")
# Act
await pipeline._logStageExecution(ctx, "s1", 5.0, False, err)
# Assert
fake_logger.warn.assert_awaited_once()
call_args = fake_logger.warn.call_args
assert call_args.kwargs["status"] == "failed"
assert call_args.kwargs["error_type"] == "ValidationError"
@pytest.mark.asyncio
async def test_log_stage_execution_noop_when_logger_is_none(self):
# Arrange
pipeline = Pipeline(name="test", stages=[_FakeStage("s1")], logger=None)
ctx = _FakeCtx()
# Act
await pipeline._logStageExecution(ctx, "s1", 1.0, True, None)
# Assert — 无异常即视为通过
# ─── stage-level spanH-4 / §11.2)───────────────────────────────────────
@pytest.mark.unit
class TestStageSpan:
"""``Pipeline.run`` 为每个 stage 包裹独立 spanH-4"""
@pytest.mark.asyncio
async def test_each_stage_generates_independent_span(self):
# Arrange
s1 = _FakeStage("session_resolve")
s2 = _FakeStage("route")
tracer = _FakeTracer()
pipeline = Pipeline(name="inbound", stages=[s1, s2], tracer_port=tracer)
ctx = _FakeCtx(trace_id="trace-1")
# Act
ok, _ = await pipeline.run(ctx)
# Assert — 每个 stage 各生成一个 span
assert ok is True
assert len(tracer.start_calls) == 2
assert len(tracer.end_calls) == 2
@pytest.mark.asyncio
async def test_span_name_contains_pipeline_name_and_stage_id(self):
# Arrange
s1 = _FakeStage("session_resolve")
tracer = _FakeTracer()
pipeline = Pipeline(name="inbound", stages=[s1], tracer_port=tracer)
# Act
await pipeline.run(_FakeCtx(trace_id="trace-2"))
# Assert — span 名格式 {pipeline_name}.{stage_id}
assert tracer.start_calls[0] == ("inbound.session_resolve", "trace-2")
@pytest.mark.asyncio
async def test_span_status_ok_on_success(self):
# Arrange
s1 = _FakeStage("deliver")
tracer = _FakeTracer()
pipeline = Pipeline(name="outbound", stages=[s1], tracer_port=tracer)
# Act
await pipeline.run(_FakeCtx())
# Assert
assert tracer.end_calls[0] == ("outbound.deliver", "ok")
@pytest.mark.asyncio
async def test_span_closed_with_error_status_on_exception(self):
# Arrange — stage 抛原生异常span 仍需在 finally 中关闭
s1 = _FakeStage(
"deliver",
raise_error=RuntimeError("boom"),
failure=FailureStrategy.TERMINATE,
)
tracer = _FakeTracer()
pipeline = Pipeline(name="outbound", stages=[s1], tracer_port=tracer)
# Act
ok, err = await pipeline.run(_FakeCtx(trace_id="trace-err"))
# Assert — span 被关闭try/finally状态为 error
assert ok is False
assert err is not None
assert len(tracer.end_calls) == 1
assert tracer.end_calls[0] == ("outbound.deliver", "error")
@pytest.mark.asyncio
async def test_span_closed_with_error_status_when_stage_returns_false(self):
# Arrange — stage 返回 False非异常失败span 状态为 error
s1 = _FakeStage("dispatch", result=False, failure=FailureStrategy.TERMINATE)
tracer = _FakeTracer()
pipeline = Pipeline(name="control-plane", stages=[s1], tracer_port=tracer)
# Act
await pipeline.run(_FakeCtx())
# Assert
assert tracer.end_calls[0] == ("control-plane.dispatch", "error")
@pytest.mark.asyncio
async def test_no_span_when_tracer_port_is_none(self):
# Arrange — tracer_port=None默认向后兼容不生成 span
s1 = _FakeStage("resolve")
pipeline = Pipeline(name="inbound", stages=[s1])
# Act
ok, _ = await pipeline.run(_FakeCtx())
# Assert — 无异常即通过tracer_port 为 None 时跳过 span 包裹)
assert ok is True
@pytest.mark.asyncio
async def test_condition_skipped_stage_has_no_span(self):
# Arrange — condition 返回 False 的 stage 不生成 span
s1 = _FakeStage("skipped", condition=lambda _ctx: False)
s2 = _FakeStage("route")
tracer = _FakeTracer()
pipeline = Pipeline(name="inbound", stages=[s1, s2], tracer_port=tracer)
# Act
await pipeline.run(_FakeCtx())
# Assert — 仅执行 stage 生成 span
assert len(tracer.start_calls) == 1
assert tracer.start_calls[0][0] == "inbound.route"